From a213ad4f02d02d4e99272d27d39c8c5a8b62e707 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 4 Mar 2026 17:16:37 -0800 Subject: [PATCH 1/6] Immutible string type --- darn_cli/src/commands.rs | 58 +++++++++++------ darn_cli/src/main.rs | 26 +++++++- darn_core/src/darn.rs | 27 +++++--- darn_core/src/discover.rs | 16 ++--- darn_core/src/dotfile.rs | 9 +++ darn_core/src/file.rs | 107 ++++++++++++++++++++++++++------ darn_core/src/file/content.rs | 38 ++++++++++-- darn_core/src/file/file_type.rs | 27 +++++++- darn_core/src/ignore.rs | 9 ++- darn_core/src/refresh.rs | 9 ++- darn_core/src/watcher.rs | 3 +- darn_core/tests/integration.rs | 12 ++-- 12 files changed, 262 insertions(+), 79 deletions(-) diff --git a/darn_cli/src/commands.rs b/darn_cli/src/commands.rs index ad7945f..54c3b52 100644 --- a/darn_cli/src/commands.rs +++ b/darn_cli/src/commands.rs @@ -17,6 +17,7 @@ use std::{ use console::Style; use darn_core::{ + attributes::AttributeRules, darn::Darn, directory::{Directory, entry::EntryType, sedimentree_id_to_url}, discover::{DiscoverProgress, DiscoverResult}, @@ -122,12 +123,18 @@ pub(crate) async fn init( path: &Path, peer_url: Option<&str>, peer_name_override: Option<&str>, + force_immutable: bool, out: Output, ) -> eyre::Result<()> { out.intro("darn init")?; // Initialize workspace structure - let initialized = Darn::init(path)?; + let mut initialized = Darn::init(path)?; + + // Set force_immutable in the .darn config if requested + if force_immutable { + initialized.set_force_immutable(true)?; + } let root = initialized.root().to_path_buf(); out.success(&format!("Initialized workspace at {}", root.display()))?; @@ -422,11 +429,7 @@ pub(crate) async fn clone_cmd(root_id_str: &str, path: &Path, out: Output) -> ey } }; - let file_type = if file.content.is_text() { - FileType::Text - } else { - FileType::Binary - }; + let file_type = FileType::from(&file.content); let sed_digest = sedimentree::compute_digest(darn.subduction(), entry.sedimentree_id).await?; @@ -773,6 +776,7 @@ pub(crate) async fn stat(target: &str, out: Output) -> eyre::Result<()> { let file_type_str = match tracked.file_type { FileType::Text => "text", FileType::Binary => "binary", + FileType::Immutable => "immutable", }; let sed_id_str = sedimentree_id_to_url(sed_id); @@ -873,9 +877,10 @@ pub(crate) async fn sync_cmd( peer_name: Option<&str>, dry_run: bool, force: bool, + force_immutable: bool, out: Output, ) -> eyre::Result<()> { - info!(?peer_name, dry_run, force, "Syncing"); + info!(?peer_name, dry_run, force, force_immutable, "Syncing"); // Porcelain mode implies --force (no interactive prompts) let force = force || out.is_porcelain(); @@ -891,8 +896,13 @@ pub(crate) async fn sync_cmd( let mut manifest = darn.load_manifest()?; spinner.stop("Workspace loaded"); + // Merge persistent config + CLI flag + let force_immutable = force_immutable || darn.config().force_immutable; + // Phase 1: Discover and optionally ingest new files - if let Err(early) = sync_discover_files(&darn, &mut manifest, force, &out).await { + if let Err(early) = + sync_discover_files(&darn, &mut manifest, force, force_immutable, &out).await + { // scan_new_files failed — continue straight to sync out.warning(&format!("File scan error: {early}"))?; } @@ -908,6 +918,7 @@ async fn sync_discover_files( darn: &Darn, manifest: &mut Manifest, force: bool, + force_immutable: bool, out: &Output, ) -> Result<(), String> { let spinner = out.spinner("Scanning for new files..."); @@ -947,7 +958,7 @@ async fn sync_discover_files( let should_track = force || out.confirm("Track these files?", true).unwrap_or(false); if should_track { - sync_ingest_files(darn, manifest, candidates, out) + sync_ingest_files(darn, manifest, candidates, force_immutable, out) .await .map_err(|e| e.to_string())?; } else { @@ -963,6 +974,7 @@ async fn sync_ingest_files( darn: &Darn, manifest: &mut Manifest, candidates: Vec, + force_immutable: bool, out: &Output, ) -> eyre::Result<()> { let total_files = candidates.len(); @@ -994,7 +1006,13 @@ async fn sync_ingest_files( }; let result = darn - .ingest_files(candidates, manifest, progress_callback, &cancel_token) + .ingest_files( + candidates, + manifest, + force_immutable, + progress_callback, + &cancel_token, + ) .await; match result { @@ -1526,6 +1544,7 @@ fn format_timestamp(ts: darn_core::unix_timestamp::UnixTimestamp) -> String { pub(crate) async fn watch( sync_interval: &std::time::Duration, no_track: bool, + force_immutable: bool, out: Output, ) -> eyre::Result<()> { out.intro("darn watch")?; @@ -1536,6 +1555,9 @@ pub(crate) async fn watch( let mut manifest = darn.load_manifest()?; spinner.stop("Workspace loaded"); + // Merge persistent config + CLI flag + let force_immutable = force_immutable || darn.config().force_immutable; + info!(root = %root.display(), ?sync_interval, no_track, "Starting watch"); out.info(&format!("Watching {}", root.display()))?; @@ -1761,7 +1783,9 @@ pub(crate) async fn watch( // Track new files if !batch.created.is_empty() && !no_track { for path in &batch.created { - match track_single_file(&darn, &mut manifest, path).await { + match track_single_file(&darn, &mut manifest, path, force_immutable) + .await + { Ok(()) => { info!(path = %path.display(), "Auto-tracked file"); } @@ -1993,16 +2017,14 @@ async fn track_single_file( darn: &Darn, manifest: &mut Manifest, relative_path: &Path, + force_immutable: bool, ) -> eyre::Result<()> { let full_path = darn.root().join(relative_path); - // Create File from path - let doc = File::from_path(&full_path)?; - let file_type = if doc.content.is_text() { - FileType::Text - } else { - FileType::Binary - }; + // Create File from path (with attribute rules + force_immutable) + let attributes = AttributeRules::from_workspace_root(darn.root()).ok(); + let doc = File::from_path_full(&full_path, attributes.as_ref(), force_immutable)?; + let file_type = FileType::from(&doc.content); // Convert to Automerge let mut am_doc = doc.into_automerge()?; diff --git a/darn_cli/src/main.rs b/darn_cli/src/main.rs index 06fa21a..d8f84b9 100644 --- a/darn_cli/src/main.rs +++ b/darn_cli/src/main.rs @@ -51,7 +51,10 @@ async fn main() -> Result<()> { path, peer, peer_name, - } => commands::init(&path, peer.as_deref(), peer_name.as_deref(), out).await, + force_immutable, + } => { + commands::init(&path, peer.as_deref(), peer_name.as_deref(), force_immutable, out).await + } Commands::Clone { root_id, path } => commands::clone_cmd(&root_id, &path, out).await, Commands::Ignore { patterns } => commands::ignore(&patterns, out), Commands::Unignore { patterns } => commands::unignore(&patterns, out), @@ -61,8 +64,13 @@ async fn main() -> Result<()> { peer, dry_run, force, - } => commands::sync_cmd(peer.as_deref(), dry_run, force, out).await, - Commands::Watch { interval, no_track } => commands::watch(&interval, no_track, out).await, + force_immutable, + } => commands::sync_cmd(peer.as_deref(), dry_run, force, force_immutable, out).await, + Commands::Watch { + interval, + no_track, + force_immutable, + } => commands::watch(&interval, no_track, force_immutable, out).await, Commands::Info => commands::info(out), Commands::Peer { command } => match command { PeerCommands::Add { @@ -110,6 +118,10 @@ enum Commands { /// Name for the peer (defaults to hostname from URL) #[arg(long, requires = "peer")] peer_name: Option, + + /// Store new text files as immutable strings (LWW, no character merging) + #[arg(long)] + force_immutable: bool, }, /// Clone a workspace by root directory ID (syncs from global peers) @@ -156,6 +168,10 @@ enum Commands { /// Skip confirmation for new file discovery #[arg(long, short)] force: bool, + + /// Store new text files as immutable strings (LWW, no character merging) + #[arg(long)] + force_immutable: bool, }, /// Watch for file changes and auto-sync @@ -167,6 +183,10 @@ enum Commands { /// Disable auto-tracking of new files #[arg(long)] no_track: bool, + + /// Store new text files as immutable strings (LWW, no character merging) + #[arg(long)] + force_immutable: bool, }, /// Show info about global config and current workspace diff --git a/darn_core/src/darn.rs b/darn_core/src/darn.rs index 899877e..1a706e7 100644 --- a/darn_core/src/darn.rs +++ b/darn_core/src/darn.rs @@ -711,11 +711,7 @@ impl Darn { } }; - let file_type = if file.content.is_text() { - FileType::Text - } else { - FileType::Binary - }; + let file_type = FileType::from(&file.content); if let Err(e) = staged.stage_write( &file, @@ -865,11 +861,7 @@ impl Darn { } }; - let file_type = if file.content.is_text() { - FileType::Text - } else { - FileType::Binary - }; + let file_type = FileType::from(&file.content); let sed_digest = match sedimentree::compute_digest(&self.subduction, entry.sedimentree_id) @@ -1152,6 +1144,7 @@ impl Darn { &self, paths: Vec, manifest: &mut Manifest, + force_immutable: bool, on_progress: F, cancel: &CancellationToken, ) -> Result @@ -1163,6 +1156,7 @@ impl Darn { &self.root, &self.subduction, manifest, + force_immutable, on_progress, cancel, ) @@ -1585,6 +1579,19 @@ impl InitializedDarn { self.layout.manifest_path() } + /// Set `force_immutable` in the `.darn` config and save it. + /// + /// # Errors + /// + /// Returns an error if the config file cannot be written. + pub fn set_force_immutable( + &mut self, + force_immutable: bool, + ) -> Result<(), crate::dotfile::DotfileError> { + self.config.force_immutable = force_immutable; + self.config.save(&self.root) + } + /// Get the peer ID from the global signer. /// /// # Errors diff --git a/darn_core/src/discover.rs b/darn_core/src/discover.rs index 0cd311d..2399076 100644 --- a/darn_core/src/discover.rs +++ b/darn_core/src/discover.rs @@ -221,6 +221,7 @@ async fn store_single_file( root: &Path, subduction: &DarnSubduction, attributes: &AttributeRules, + force_immutable: bool, ) -> Result { let relative_path = path .strip_prefix(root) @@ -232,14 +233,11 @@ async fn store_single_file( let path_owned = path.to_path_buf(); let attributes_default = attributes.clone(); let (file_type, am_doc) = tokio::task::spawn_blocking(move || { - let doc = File::from_path_with_attributes(&path_owned, Some(&attributes_default)) - .map_err(FileProcessError::Read)?; + let doc = + File::from_path_full(&path_owned, Some(&attributes_default), force_immutable) + .map_err(FileProcessError::Read)?; - let file_type = if doc.content.is_text() { - FileType::Text - } else { - FileType::Binary - }; + let file_type = FileType::from(&doc.content); let am_doc = doc.into_automerge().map_err(FileProcessError::Automerge)?; Ok::<_, FileProcessError>((file_type, am_doc)) @@ -382,6 +380,7 @@ pub(crate) async fn ingest_files_parallel( root: &Path, subduction: &DarnSubduction, manifest: &Manifest, + force_immutable: bool, on_progress: F, cancel: &CancellationToken, ) -> (Vec, Vec<(PathBuf, String)>, bool) @@ -427,7 +426,8 @@ where in_flight.fetch_add(1, Ordering::Relaxed); - let result = store_single_file(&path, root, subduction, attributes).await; + let result = + store_single_file(&path, root, subduction, attributes, force_immutable).await; in_flight.fetch_sub(1, Ordering::Relaxed); completed.fetch_add(1, Ordering::Relaxed); diff --git a/darn_core/src/dotfile.rs b/darn_core/src/dotfile.rs index c1c65aa..22b639a 100644 --- a/darn_core/src/dotfile.rs +++ b/darn_core/src/dotfile.rs @@ -75,6 +75,12 @@ pub struct DarnConfig { #[serde(with = "serde_base58::sedimentree_id")] pub root_directory_id: SedimentreeId, + /// When true, newly ingested text files use LWW string semantics + /// (`ScalarValue::Str`) instead of character-level CRDT merging. + /// Binary files are unaffected. Already-tracked files keep their type. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_immutable: bool, + /// Gitignore-style patterns to exclude from sync. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ignore: Vec, @@ -111,6 +117,7 @@ impl DarnConfig { Self { id, root_directory_id, + force_immutable: false, ignore: default_ignore_patterns(), attributes: default_attribute_map(), } @@ -121,12 +128,14 @@ impl DarnConfig { pub const fn with_fields( id: WorkspaceId, root_directory_id: SedimentreeId, + force_immutable: bool, ignore: Vec, attributes: AttributeMap, ) -> Self { Self { id, root_directory_id, + force_immutable, ignore, attributes, } diff --git a/darn_core/src/file.rs b/darn_core/src/file.rs index c106ed3..b21a023 100644 --- a/darn_core/src/file.rs +++ b/darn_core/src/file.rs @@ -28,7 +28,7 @@ use std::{ path::{Path, PathBuf}, }; -use automerge::{Automerge, AutomergeError, ObjType, ROOT, ReadDoc, transaction::Transactable}; +use automerge::{transaction::Transactable, Automerge, AutomergeError, ObjType, ReadDoc, ROOT}; use thiserror::Error; use crate::attributes::AttributeRules; @@ -80,6 +80,16 @@ impl File { } } + /// Creates a new immutable text file document (LWW string, no character merging). + #[must_use] + pub fn immutable(name: impl Into, content: impl Into) -> Self { + Self { + name: name::Name::new(name), + content: content::Content::ImmutableString(content.into()), + metadata: metadata::Metadata::default(), + } + } + /// Creates a file document from a filesystem path. /// /// Automatically detects whether the file is text or binary using streaming @@ -89,7 +99,7 @@ impl File { /// /// Returns an error if the file cannot be read. pub fn from_path(path: &Path) -> Result { - Self::from_path_with_attributes(path, None) + Self::from_path_full(path, None, false) } /// Creates a file document from a filesystem path with attribute rules. @@ -104,6 +114,25 @@ impl File { pub fn from_path_with_attributes( path: &Path, attributes: Option<&AttributeRules>, + ) -> Result { + Self::from_path_full(path, attributes, false) + } + + /// Creates a file document from a filesystem path with full options. + /// + /// When `force_immutable` is true, text files are stored as + /// [`Content::ImmutableString`] (LWW string) instead of [`Content::Text`] + /// (character-level CRDT). Binary files are unaffected. This only + /// applies to _newly ingested_ files — already-tracked files keep their + /// existing type on refresh. + /// + /// # Errors + /// + /// Returns an error if the file cannot be read. + pub fn from_path_full( + path: &Path, + attributes: Option<&AttributeRules>, + force_immutable: bool, ) -> Result { let name = name::Name::from_path(path) .ok_or_else(|| ReadFileError::InvalidPath(path.to_path_buf()))?; @@ -122,12 +151,21 @@ impl File { // Check if attributes specify a file type let file_content = match attributes.and_then(|a| a.get_attribute(path)) { Some(file_type::FileType::Binary) => { - // Explicitly binary - read as bytes without UTF-8 check + // Explicitly binary — read as bytes without UTF-8 check content::Content::Bytes(std::fs::read(path)?) } + Some(file_type::FileType::Immutable) => { + // Explicitly immutable — LWW string + content::Content::ImmutableString(std::fs::read_to_string(path)?) + } Some(file_type::FileType::Text) => { - // Explicitly text - read as string (will fail if not valid UTF-8) - content::Content::Text(std::fs::read_to_string(path)?) + // Explicitly text — force_immutable overrides to LWW string + let s = std::fs::read_to_string(path)?; + if force_immutable { + content::Content::ImmutableString(s) + } else { + content::Content::Text(s) + } } None => { // Large files default to binary — character-level CRDT is too expensive @@ -135,7 +173,15 @@ impl File { content::Content::Bytes(std::fs::read(path)?) } else { // Auto-detect using streaming UTF-8 validation - streaming_utf8_read(path)? + let detected = streaming_utf8_read(path)?; + if force_immutable { + match detected { + content::Content::Text(s) => content::Content::ImmutableString(s), + other => other, // Binary stays binary + } + } else { + detected + } } } }; @@ -166,7 +212,8 @@ impl File { let mut doc = Automerge::new(); let extension = extract_extension(self.name.as_str()); - let mime_type = mime_type_for_extension(&extension, self.content.is_text()); + let is_readable_text = self.content.is_text() || self.content.is_immutable_string(); + let mime_type = mime_type_for_extension(&extension, is_readable_text); let mode = self.metadata.mode(); doc.transact::<_, _, AutomergeError>(|tx| { @@ -190,6 +237,13 @@ impl File { automerge::ScalarValue::Bytes(bytes.clone()), )?; } + content::Content::ImmutableString(text) => { + tx.put( + ROOT, + "content", + automerge::ScalarValue::Str(text.clone().into()), + )?; + } } let ext_obj = tx.put_object(ROOT, "extension", ObjType::Text)?; @@ -219,7 +273,8 @@ impl File { let mut doc = Automerge::new(); let extension = extract_extension(self.name.as_str()); - let mime_type = mime_type_for_extension(&extension, self.content.is_text()); + let is_readable_text = self.content.is_text() || self.content.is_immutable_string(); + let mime_type = mime_type_for_extension(&extension, is_readable_text); let mode = self.metadata.mode(); let name = self.name; let content = self.content; @@ -233,18 +288,25 @@ impl File { let name_obj = tx.put_object(ROOT, "name", ObjType::Text)?; tx.splice_text(&name_obj, 0, 0, name.as_str())?; - match content { - content::Content::Text(ref text) => { + match &content { + content::Content::Text(text) => { let text_obj = tx.put_object(ROOT, "content", ObjType::Text)?; tx.splice_text(&text_obj, 0, 0, text)?; } - content::Content::Bytes(ref bytes) => { + content::Content::Bytes(bytes) => { tx.put( ROOT, "content", automerge::ScalarValue::Bytes(bytes.clone()), )?; } + content::Content::ImmutableString(text) => { + tx.put( + ROOT, + "content", + automerge::ScalarValue::Str(text.clone().into()), + )?; + } } let ext_obj = tx.put_object(ROOT, "extension", ObjType::Text)?; @@ -275,15 +337,17 @@ impl File { let text = doc.text(&id)?; content::Content::Text(text) } - Some((automerge::Value::Scalar(s), _)) => { - if let automerge::ScalarValue::Bytes(bytes) = s.as_ref() { - content::Content::Bytes(bytes.clone()) - } else { + Some((automerge::Value::Scalar(s), _)) => match s.as_ref() { + automerge::ScalarValue::Bytes(bytes) => content::Content::Bytes(bytes.clone()), + automerge::ScalarValue::Str(smol_str) => { + content::Content::ImmutableString(smol_str.to_string()) + } + _ => { return Err(DeserializeError::InvalidSchema( - "content must be Text or Bytes".into(), + "content must be Text, Str, or Bytes".into(), )); } - } + }, _ => { return Err(DeserializeError::InvalidSchema( "missing content field".into(), @@ -325,7 +389,9 @@ impl File { /// Returns an error if the file cannot be written. pub fn write_to_staging(&self, path: &Path) -> Result<(), WriteFileError> { match &self.content { - content::Content::Text(text) => std::fs::write(path, text)?, + content::Content::Text(text) | content::Content::ImmutableString(text) => { + std::fs::write(path, text)?; + } content::Content::Bytes(bytes) => std::fs::write(path, bytes)?, } @@ -357,7 +423,9 @@ impl File { // Write content to temp file match &self.content { - content::Content::Text(text) => std::fs::write(&temp_path, text)?, + content::Content::Text(text) | content::Content::ImmutableString(text) => { + std::fs::write(&temp_path, text)?; + } content::Content::Bytes(bytes) => std::fs::write(&temp_path, bytes)?, } @@ -665,6 +733,7 @@ mod tests { let config = DarnConfig::with_fields( WorkspaceId::from_bytes([1; 16]), SedimentreeId::new([2; 32]), + false, Vec::new(), AttributeMap { binary: Vec::new(), diff --git a/darn_core/src/file/content.rs b/darn_core/src/file/content.rs index 4540d9c..0c5e12a 100644 --- a/darn_core/src/file/content.rs +++ b/darn_core/src/file/content.rs @@ -4,8 +4,9 @@ use super::file_type::FileType; /// Content stored in a file document. /// -/// Text files get character-level CRDT merging, while binary files -/// use last-writer-wins semantics. +/// Text files get character-level CRDT merging, binary files use +/// last-writer-wins byte replacement, and immutable text files store +/// UTF-8 strings with last-writer-wins replacement (no character merging). /// /// # Future Work /// @@ -19,11 +20,19 @@ pub enum Content { /// Binary content (last-writer-wins). Bytes(Vec), + + /// UTF-8 text content with last-writer-wins semantics. + /// + /// Stored as an Automerge `ScalarValue::Str` — the entire string is + /// replaced atomically on update. Human-readable in Patchwork/JS + /// (appears as a plain string, not a `Uint8Array`), but without + /// character-level merge support. + ImmutableString(String), // TODO Consider adding large file support with external blob references } impl Content { - /// Returns `true` if this is text content. + /// Returns `true` if this is text content (character-level CRDT). #[must_use] pub const fn is_text(&self) -> bool { matches!(self, Self::Text(_)) @@ -35,11 +44,17 @@ impl Content { matches!(self, Self::Bytes(_)) } - /// Returns the text content if this is a text document. + /// Returns `true` if this is immutable text (LWW string). + #[must_use] + pub const fn is_immutable_string(&self) -> bool { + matches!(self, Self::ImmutableString(_)) + } + + /// Returns the text content if this is a text or immutable text document. #[must_use] pub fn as_text(&self) -> Option<&str> { match self { - Self::Text(s) => Some(s), + Self::Text(s) | Self::ImmutableString(s) => Some(s), Self::Bytes(_) => None, } } @@ -48,7 +63,7 @@ impl Content { #[must_use] pub fn as_bytes(&self) -> Option<&[u8]> { match self { - Self::Text(_) => None, + Self::Text(_) | Self::ImmutableString(_) => None, Self::Bytes(b) => Some(b), } } @@ -59,6 +74,17 @@ impl From for FileType { match c { Content::Text(_) => FileType::Text, Content::Bytes(_) => FileType::Binary, + Content::ImmutableString(_) => FileType::Immutable, + } + } +} + +impl From<&Content> for FileType { + fn from(c: &Content) -> Self { + match c { + Content::Text(_) => FileType::Text, + Content::Bytes(_) => FileType::Binary, + Content::ImmutableString(_) => FileType::Immutable, } } } diff --git a/darn_core/src/file/file_type.rs b/darn_core/src/file/file_type.rs index 4322829..8c51543 100644 --- a/darn_core/src/file/file_type.rs +++ b/darn_core/src/file/file_type.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; /// Content kind determines the CRDT merge strategy. /// -/// Serialized as lowercase strings: `"text"` or `"binary"`. +/// Serialized as lowercase strings: `"text"`, `"binary"`, or `"immutable"`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum FileType { @@ -15,10 +15,18 @@ pub enum FileType { /// Last-writer-wins binary (Automerge `Bytes`). Binary, + + /// Last-writer-wins text (Automerge `ScalarValue::Str`). + /// + /// Content is valid UTF-8 and human-readable, but the entire string is + /// replaced atomically on update — no character-level merging. Useful + /// when you want text files stored as readable strings but don't need + /// collaborative editing semantics. + Immutable, } impl FileType { - /// Returns `true` if this is text content. + /// Returns `true` if this is text content (character-level CRDT). #[must_use] pub const fn is_text(self) -> bool { matches!(self, Self::Text) @@ -30,9 +38,17 @@ impl FileType { matches!(self, Self::Binary) } + /// Returns `true` if this is immutable text (LWW string). + #[must_use] + pub const fn is_immutable(self) -> bool { + matches!(self, Self::Immutable) + } + /// Parse from a MIME type string. /// /// Any `text/*` MIME type maps to `Text`, everything else to `Binary`. + /// Note: `Immutable` cannot be inferred from MIME type alone — it + /// requires an explicit override. #[must_use] pub fn from_mime_type(mime: &str) -> Self { if mime.starts_with("text/") { @@ -48,6 +64,7 @@ impl fmt::Display for FileType { match self { Self::Text => f.write_str("text/plain"), Self::Binary => f.write_str("application/octet-stream"), + Self::Immutable => f.write_str("text/plain; immutable"), } } } @@ -69,6 +86,10 @@ impl<'b, Ctx> minicbor::Decode<'b, Ctx> for FileType { _ctx: &mut Ctx, ) -> Result { let mime = d.str()?; - Ok(Self::from_mime_type(mime)) + if mime == "text/plain; immutable" { + Ok(Self::Immutable) + } else { + Ok(Self::from_mime_type(mime)) + } } } diff --git a/darn_core/src/ignore.rs b/darn_core/src/ignore.rs index 1f3e944..d9777e1 100644 --- a/darn_core/src/ignore.rs +++ b/darn_core/src/ignore.rs @@ -158,8 +158,13 @@ mod tests { fn create_test_config(root: &Path, ignore: Vec) { let id = WorkspaceId::from_bytes([1; 16]); let sed_id = SedimentreeId::new([2; 32]); - let config = - DarnConfig::with_fields(id, sed_id, ignore, crate::dotfile::AttributeMap::default()); + let config = DarnConfig::with_fields( + id, + sed_id, + false, + ignore, + crate::dotfile::AttributeMap::default(), + ); config.save(root).expect("save test config"); } diff --git a/darn_core/src/refresh.rs b/darn_core/src/refresh.rs index d8f640c..65739d4 100644 --- a/darn_core/src/refresh.rs +++ b/darn_core/src/refresh.rs @@ -1,6 +1,6 @@ //! Refresh error types and Automerge content update helpers. -use automerge::{Automerge, AutomergeError, ObjType, ROOT, ReadDoc, transaction::Transactable}; +use automerge::{transaction::Transactable, Automerge, AutomergeError, ObjType, ReadDoc, ROOT}; use thiserror::Error; use crate::file::content::Content; @@ -27,7 +27,7 @@ pub fn update_automerge_content( doc: &mut Automerge, new_content: Content, ) -> Result<(), RefreshError> { - // For text, we need to get the content_id first (read-only) + // For text (CRDT), we need to get the content_id first (read-only) let content_info = match &new_content { Content::Text(_) => { let Some((automerge::Value::Object(ObjType::Text), content_id)) = @@ -40,7 +40,7 @@ pub fn update_automerge_content( let old_len = doc.text(&content_id)?.chars().count(); Some((content_id, old_len)) } - Content::Bytes(_) => None, + Content::Bytes(_) | Content::ImmutableString(_) => None, }; doc.transact::<_, _, AutomergeError>(|tx| { @@ -55,6 +55,9 @@ pub fn update_automerge_content( Content::Bytes(bytes) => { tx.put(ROOT, "content", automerge::ScalarValue::Bytes(bytes))?; } + Content::ImmutableString(text) => { + tx.put(ROOT, "content", automerge::ScalarValue::Str(text.into()))?; + } } Ok(()) }) diff --git a/darn_core/src/watcher.rs b/darn_core/src/watcher.rs index 28100f8..23fa4c1 100644 --- a/darn_core/src/watcher.rs +++ b/darn_core/src/watcher.rs @@ -29,7 +29,7 @@ use std::{ time::Duration, }; -use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer, notify::RecursiveMode}; +use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode, DebounceEventResult, Debouncer}; use thiserror::Error; use tokio::sync::mpsc; @@ -495,6 +495,7 @@ mod tests { let config = DarnConfig::with_fields( WorkspaceId::from_bytes([1; 16]), sedimentree_core::id::SedimentreeId::new([2; 32]), + false, vec!["*.log".to_string()], AttributeMap::default(), ); diff --git a/darn_core/tests/integration.rs b/darn_core/tests/integration.rs index 90cbfa8..c8f54fe 100644 --- a/darn_core/tests/integration.rs +++ b/darn_core/tests/integration.rs @@ -483,7 +483,7 @@ async fn ingest_and_track_files() -> TestResult { let cancel = tokio_util::sync::CancellationToken::new(); let result = darn - .ingest_files(paths, &mut manifest, |_| {}, &cancel) + .ingest_files(paths, &mut manifest, false, |_| {}, &cancel) .await?; assert_eq!(result.new_files.len(), 2); assert!(result.errors.is_empty()); @@ -527,7 +527,7 @@ async fn ingest_skips_ignored_via_scan() -> TestResult { let cancel = tokio_util::sync::CancellationToken::new(); let result = darn - .ingest_files(paths, &mut manifest, |_| {}, &cancel) + .ingest_files(paths, &mut manifest, false, |_| {}, &cancel) .await?; assert_eq!(result.new_files.len(), 1); @@ -554,7 +554,7 @@ async fn refresh_detects_modified_file() -> TestResult { std::fs::write(env.workspace().join("file.txt"), "original")?; let paths = darn.scan_new_files(&manifest)?; let cancel = tokio_util::sync::CancellationToken::new(); - darn.ingest_files(paths, &mut manifest, |_| {}, &cancel) + darn.ingest_files(paths, &mut manifest, false, |_| {}, &cancel) .await?; let entry = manifest @@ -596,7 +596,7 @@ async fn refresh_detects_missing_file() -> TestResult { std::fs::write(env.workspace().join("doomed.txt"), "bye")?; let paths = darn.scan_new_files(&manifest)?; let cancel = tokio_util::sync::CancellationToken::new(); - darn.ingest_files(paths, &mut manifest, |_| {}, &cancel) + darn.ingest_files(paths, &mut manifest, false, |_| {}, &cancel) .await?; std::fs::remove_file(env.workspace().join("doomed.txt"))?; @@ -685,7 +685,7 @@ async fn staged_update_handles_mixed_creates_and_deletes() -> TestResult { std::fs::write(env.workspace().join("old.txt"), "old content")?; let paths = darn.scan_new_files(&manifest)?; let cancel = tokio_util::sync::CancellationToken::new(); - darn.ingest_files(paths, &mut manifest, |_| {}, &cancel) + darn.ingest_files(paths, &mut manifest, false, |_| {}, &cancel) .await?; let old_entry = manifest @@ -792,7 +792,7 @@ async fn full_local_workflow() -> TestResult { // 5. Ingest discovered files let cancel = tokio_util::sync::CancellationToken::new(); let result = darn - .ingest_files(paths, &mut manifest, |_| {}, &cancel) + .ingest_files(paths, &mut manifest, false, |_| {}, &cancel) .await?; assert_eq!(result.new_files.len(), 2); assert!(result.errors.is_empty()); From 131d0ff8bfb5b1a28ab25decee58d5aab8d5b2b2 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 4 Mar 2026 17:31:28 -0800 Subject: [PATCH 2/6] Silent & quiet flags --- Cargo.lock | 4 +- Cargo.toml | 2 +- darn_cli/src/commands.rs | 291 ++++++++++++++++++++++----------------- darn_cli/src/main.rs | 22 ++- darn_cli/src/output.rs | 211 +++++++++++++++++++++++++--- darn_cli/src/setup.rs | 21 +-- 6 files changed, 385 insertions(+), 166 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 703a365..6efdd18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -613,9 +613,9 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "cliclack" -version = "0.3.9" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4797110534d49f4e38465be8d84c911f3a9e0f6582f70d3aa4cb30c8fa737851" +checksum = "94c890f382ae1fde7c87631b5d6984a934cfc0345cfec9dca9005b3d8754c160" dependencies = [ "console", "indicatif", diff --git a/Cargo.toml b/Cargo.toml index e36daf8..9b876f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ color-eyre = "0.6" eyre = "0.6" bs58 = { version = "0.5", features = ["check"] } clap = { version = "4.0", features = ["derive"] } -cliclack = "0.3" +cliclack = "0.4" console = "0.16" dirs = "6.0" future_form = "0.3" diff --git a/darn_cli/src/commands.rs b/darn_cli/src/commands.rs index 54c3b52..db36203 100644 --- a/darn_cli/src/commands.rs +++ b/darn_cli/src/commands.rs @@ -655,7 +655,10 @@ pub(crate) fn tree(out: Output) -> eyre::Result<()> { FileState::Missing => "missing", }; let url = sedimentree_id_to_url(entry.sedimentree_id); - println!("{state_str}\t{}\t{url}", entry.relative_path.display()); + out.detail_porcelain(&format!( + "{state_str}\t{}\t{url}", + entry.relative_path.display() + )); } return Ok(()); } @@ -784,14 +787,14 @@ pub(crate) async fn stat(target: &str, out: Output) -> eyre::Result<()> { let sed_digest = bs58::encode(tracked.sedimentree_digest.as_bytes()).into_string(); if out.is_porcelain() { - println!("path\t{}", tracked.relative_path.display()); - println!("sedimentree\t{sed_id_str}"); - println!("state\t{state_str}"); - println!("type\t{file_type_str}"); - println!("commits\t{}", commits.len()); - println!("fragments\t{}", fragments.len()); - println!("digest_fs\t{fs_digest}"); - println!("digest_sed\t{sed_digest}"); + out.detail_porcelain(&format!("path\t{}", tracked.relative_path.display())); + out.detail_porcelain(&format!("sedimentree\t{sed_id_str}")); + out.detail_porcelain(&format!("state\t{state_str}")); + out.detail_porcelain(&format!("type\t{file_type_str}")); + out.detail_porcelain(&format!("commits\t{}", commits.len())); + out.detail_porcelain(&format!("fragments\t{}", fragments.len())); + out.detail_porcelain(&format!("digest_fs\t{fs_digest}")); + out.detail_porcelain(&format!("digest_sed\t{sed_digest}")); return Ok(()); } @@ -882,8 +885,8 @@ pub(crate) async fn sync_cmd( ) -> eyre::Result<()> { info!(?peer_name, dry_run, force, force_immutable, "Syncing"); - // Porcelain mode implies --force (no interactive prompts) - let force = force || out.is_porcelain(); + // Non-interactive modes imply --force (no interactive prompts) + let force = force || out.is_non_interactive(); if dry_run { return sync_dry_run(peer_name, out); @@ -1142,13 +1145,13 @@ async fn continue_sync( if summary.any_success() { sync_success = true; if out.is_porcelain() { - println!( + out.detail_porcelain(&format!( "synced\t{}\t{}\t{}\t{}", peer.name, summary.sedimentrees_synced, summary.total_received(), summary.total_sent() - ); + )); } else { let green = Style::new().green(); let file_count = manifest.len(); @@ -1165,7 +1168,10 @@ async fn continue_sync( peer.set_known(learned_peer_id); let id_str = bs58::encode(learned_peer_id.as_bytes()).into_string(); if out.is_porcelain() { - println!("learned_peer_id\t{}\t{id_str}", peer.name); + out.detail_porcelain(&format!( + "learned_peer_id\t{}\t{id_str}", + peer.name + )); } else { out.info(&format!( "Learned peer ID for {}: {}", @@ -1188,7 +1194,7 @@ async fn continue_sync( } Err(e) => { if out.is_porcelain() { - println!("error\t{}\t{e}", peer.name); + out.detail_porcelain(&format!("error\t{}\t{e}", peer.name)); } else { let red = Style::new().red(); out.error(&format!("{} {e}", red.apply_to(&peer.name)))?; @@ -1207,19 +1213,19 @@ async fn continue_sync( // Report results if out.is_porcelain() { for path in &apply_result.updated { - println!("updated\t{}", path.display()); + out.detail_porcelain(&format!("updated\t{}", path.display())); } for path in &apply_result.merged { - println!("merged\t{}", path.display()); + out.detail_porcelain(&format!("merged\t{}", path.display())); } for path in &apply_result.created { - println!("created\t{}", path.display()); + out.detail_porcelain(&format!("created\t{}", path.display())); } for path in &apply_result.deleted { - println!("deleted\t{}", path.display()); + out.detail_porcelain(&format!("deleted\t{}", path.display())); } for (path, err) in &apply_result.errors { - println!("error\t{}\t{err}", path.display()); + out.detail_porcelain(&format!("error\t{}\t{err}", path.display())); } } else { if !apply_result.updated.is_empty() { @@ -1268,9 +1274,11 @@ async fn continue_sync( } darn.save_manifest(&manifest)?; - out.outro("Sync complete")?; + out.summary("Sync complete")?; + out.outro("")?; } else { - out.outro("Sync failed")?; + out.summary("Sync failed")?; + out.outro("")?; } Ok(()) @@ -1290,6 +1298,7 @@ async fn sync_peer_with_progress( let current_ref = ¤t; let total_ref = &total; let is_porcelain = out.is_porcelain(); + let is_silent = out.is_silent(); let summary = darn .sync_with_peer_progress(peer, manifest, |event| { @@ -1312,12 +1321,12 @@ async fn sync_peer_with_progress( .. } => { let display_index = index + 1; - if is_porcelain { + if is_porcelain && !is_silent { let path_str = file_path .as_ref() .map_or("root_directory".to_string(), |p| p.display().to_string()); println!("syncing\t{display_index}\t{total}\t{path_str}"); - } else { + } else if !is_porcelain { let msg = match &file_path { Some(path) => format!("[{display_index}/{total}] {}", path.display()), None => format!("[{display_index}/{total}] root directory"), @@ -1370,10 +1379,10 @@ fn sync_dry_run(peer_name: Option<&str>, out: Output) -> eyre::Result<()> { if out.is_porcelain() { for path in &modified { - println!("modified\t{}", path.display()); + out.detail_porcelain(&format!("modified\t{}", path.display())); } for path in &missing { - println!("missing\t{}", path.display()); + out.detail_porcelain(&format!("missing\t{}", path.display())); } } else if !modified.is_empty() || !missing.is_empty() { #[allow(clippy::expect_used)] // Writing to String is infallible @@ -1456,14 +1465,14 @@ fn display_peer_dry_run_status( } if out.is_porcelain() { - println!( + out.detail_porcelain(&format!( "peer\t{}\t{}\t{peer_id_display}\t{last_sync}\t{}", peer.name, peer.address, unsynced.len() - ); + )); for path in &unsynced { - println!("unsynced\t{}\t{}", peer.name, path.display()); + out.detail_porcelain(&format!("unsynced\t{}\t{}", peer.name, path.display())); } } else { // Build peer status content @@ -1612,7 +1621,7 @@ pub(crate) async fn watch( }; out.remark("Press Ctrl+C to stop")?; - if !out.is_porcelain() { + if !out.is_non_interactive() { println!(); // Blank line before events } @@ -1689,7 +1698,7 @@ pub(crate) async fn watch( last_sync = std::time::Instant::now(); last_push_check = std::time::Instant::now(); - if !out.is_porcelain() { + if !out.is_non_interactive() { println!(); } } @@ -1700,6 +1709,7 @@ pub(crate) async fn watch( let red = Style::new().red(); let dim = Style::new().dim(); let is_porcelain = out.is_porcelain(); + let is_quiet = out.is_quiet(); // Event loop loop { @@ -1712,43 +1722,51 @@ pub(crate) async fn watch( match event { Some(WatchEvent::FileModified(path)) => { if processor.process(WatchEvent::FileModified(path.clone())) { - if is_porcelain { - let kind = if manifest.get_by_path(&path).is_none() { "created" } else { "modified" }; - println!("{kind}\t{}", path.display()); - } else { - let is_new = manifest.get_by_path(&path).is_none(); - if is_new { - println!(" {} {}", green.apply_to("+"), path.display()); + if !is_quiet { + if is_porcelain { + let kind = if manifest.get_by_path(&path).is_none() { "created" } else { "modified" }; + out.detail_porcelain(&format!("{kind}\t{}", path.display())); } else { - println!(" {} {}", yellow.apply_to("M"), path.display()); + let is_new = manifest.get_by_path(&path).is_none(); + if is_new { + out.detail(&format!(" {} {}", green.apply_to("+"), path.display())); + } else { + out.detail(&format!(" {} {}", yellow.apply_to("M"), path.display())); + } } } } } Some(WatchEvent::FileDeleted(path)) => { if processor.process(WatchEvent::FileDeleted(path.clone())) { - if is_porcelain { - println!("deleted\t{}", path.display()); - } else { - println!(" {} {}", red.apply_to("-"), path.display()); + if !is_quiet { + if is_porcelain { + out.detail_porcelain(&format!("deleted\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", red.apply_to("-"), path.display())); + } } } } Some(WatchEvent::FileCreated(path)) => { if processor.process(WatchEvent::FileCreated(path.clone())) { - if is_porcelain { - println!("created\t{}", path.display()); - } else { - println!(" {} {}", green.apply_to("+"), path.display()); + if !is_quiet { + if is_porcelain { + out.detail_porcelain(&format!("created\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", green.apply_to("+"), path.display())); + } } } } Some(WatchEvent::FileRenamed { from, to }) => { if processor.process(WatchEvent::FileRenamed { from: from.clone(), to: to.clone() }) { - if is_porcelain { - println!("renamed\t{}\t{}", from.display(), to.display()); - } else { - println!(" {} {} -> {}", dim.apply_to("R"), from.display(), to.display()); + if !is_quiet { + if is_porcelain { + out.detail_porcelain(&format!("renamed\t{}\t{}", from.display(), to.display())); + } else { + out.detail(&format!(" {} {} -> {}", dim.apply_to("R"), from.display(), to.display())); + } } } } @@ -1767,7 +1785,7 @@ pub(crate) async fn watch( // Check for Ctrl+C _ = tokio::signal::ctrl_c() => { - if !is_porcelain { + if !is_quiet && !is_porcelain { println!(); } out.info("Stopping...")?; @@ -1841,7 +1859,7 @@ pub(crate) async fn watch( ); if should_sync { - if !is_porcelain { + if !is_quiet && !is_porcelain { println!(); } let spinner = out.spinner("Syncing with peers..."); @@ -1882,59 +1900,67 @@ pub(crate) async fn watch( darn.save_manifest(&manifest)?; processor.update_tracked_paths(&manifest); - let mut summary = String::new(); + let mut sync_summary = String::new(); if !apply_result.updated.is_empty() { - summary.push_str(&format!("{} updated, ", apply_result.updated.len())); - for path in &apply_result.updated { - if is_porcelain { - println!("updated\t{}", path.display()); - } else { - println!(" {} {}", yellow.apply_to("U"), path.display()); + sync_summary.push_str(&format!("{} updated, ", apply_result.updated.len())); + if !is_quiet { + for path in &apply_result.updated { + if is_porcelain { + out.detail_porcelain(&format!("updated\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", yellow.apply_to("U"), path.display())); + } } } } if !apply_result.merged.is_empty() { - summary.push_str(&format!("{} merged, ", apply_result.merged.len())); - for path in &apply_result.merged { - if is_porcelain { - println!("merged\t{}", path.display()); - } else { - println!(" {} {}", yellow.apply_to("M"), path.display()); + sync_summary.push_str(&format!("{} merged, ", apply_result.merged.len())); + if !is_quiet { + for path in &apply_result.merged { + if is_porcelain { + out.detail_porcelain(&format!("merged\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", yellow.apply_to("M"), path.display())); + } } } } if !apply_result.created.is_empty() { - summary.push_str(&format!("{} new, ", apply_result.created.len())); - for path in &apply_result.created { - if is_porcelain { - println!("created\t{}", path.display()); - } else { - println!(" {} {}", green.apply_to("+"), path.display()); + sync_summary.push_str(&format!("{} new, ", apply_result.created.len())); + if !is_quiet { + for path in &apply_result.created { + if is_porcelain { + out.detail_porcelain(&format!("created\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", green.apply_to("+"), path.display())); + } } } } if !apply_result.deleted.is_empty() { - summary.push_str(&format!("{} deleted, ", apply_result.deleted.len())); - for path in &apply_result.deleted { - if is_porcelain { - println!("deleted\t{}", path.display()); - } else { - println!(" {} {}", red.apply_to("-"), path.display()); + sync_summary.push_str(&format!("{} deleted, ", apply_result.deleted.len())); + if !is_quiet { + for path in &apply_result.deleted { + if is_porcelain { + out.detail_porcelain(&format!("deleted\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", red.apply_to("-"), path.display())); + } } } } - if summary.is_empty() { + if sync_summary.is_empty() { match (any_received, any_sent) { - (true, true) => summary = "synced".to_string(), - (true, false) => summary = "received updates".to_string(), - (false, true) => summary = "sent updates".to_string(), - (false, false) => summary = "no changes".to_string(), + (true, true) => sync_summary = "synced".to_string(), + (true, false) => sync_summary = "received updates".to_string(), + (false, true) => sync_summary = "sent updates".to_string(), + (false, false) => sync_summary = "no changes".to_string(), } } else { - summary = summary.trim_end_matches(", ").to_string(); + sync_summary = sync_summary.trim_end_matches(", ").to_string(); } - spinner.stop(format!("Synced ({summary})")); + spinner.stop(format!("Synced ({sync_summary})")); } else { spinner.stop("Sync complete"); } @@ -1942,7 +1968,7 @@ pub(crate) async fn watch( last_sync = std::time::Instant::now(); last_push_check = std::time::Instant::now(); has_local_changes = false; - if !is_porcelain { + if !is_quiet && !is_porcelain { println!(); } } @@ -1960,32 +1986,34 @@ pub(crate) async fn watch( darn.save_manifest(&manifest)?; processor.update_tracked_paths(&manifest); - for path in &apply_result.updated { - if is_porcelain { - println!("updated\t{}", path.display()); - } else { - println!(" {} {}", yellow.apply_to("U"), path.display()); + if !is_quiet { + for path in &apply_result.updated { + if is_porcelain { + out.detail_porcelain(&format!("updated\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", yellow.apply_to("U"), path.display())); + } } - } - for path in &apply_result.merged { - if is_porcelain { - println!("merged\t{}", path.display()); - } else { - println!(" {} {}", yellow.apply_to("M"), path.display()); + for path in &apply_result.merged { + if is_porcelain { + out.detail_porcelain(&format!("merged\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", yellow.apply_to("M"), path.display())); + } } - } - for path in &apply_result.created { - if is_porcelain { - println!("created\t{}", path.display()); - } else { - println!(" {} {}", green.apply_to("+"), path.display()); + for path in &apply_result.created { + if is_porcelain { + out.detail_porcelain(&format!("created\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", green.apply_to("+"), path.display())); + } } - } - for path in &apply_result.deleted { - if is_porcelain { - println!("deleted\t{}", path.display()); - } else { - println!(" {} {}", red.apply_to("-"), path.display()); + for path in &apply_result.deleted { + if is_porcelain { + out.detail_porcelain(&format!("deleted\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", red.apply_to("-"), path.display())); + } } } @@ -2136,9 +2164,9 @@ pub(crate) fn peer_add( info!(%name, %addr_display, "Added peer"); if out.is_porcelain() { - println!("name\t{name}"); - println!("address\t{addr_display}"); - println!("peer_id\t{peer_id_display}"); + out.detail_porcelain(&format!("name\t{name}")); + out.detail_porcelain(&format!("address\t{addr_display}")); + out.detail_porcelain(&format!("peer_id\t{peer_id_display}")); } else { out.success(&format!("Added peer: {name} ({addr_display})"))?; out.remark(&format!("Peer ID: {peer_id_display}"))?; @@ -2202,10 +2230,10 @@ pub(crate) fn peer_list(out: Output) -> eyre::Result<()> { let last_sync = peer .last_synced_at .map_or_else(|| "never".to_string(), |ts| ts.as_secs().to_string()); - println!( + out.detail_porcelain(&format!( "{}\t{}\t{mode}\t{peer_id_display}\t{last_sync}", peer.name, peer.address - ); + )); } return Ok(()); } @@ -2252,12 +2280,12 @@ pub(crate) fn peer_remove(name: &str, out: Output) -> eyre::Result<()> { if darn.remove_peer(&peer_name)? { info!(%name, "Removed peer"); if out.is_porcelain() { - println!("removed\t{name}"); + out.detail_porcelain(&format!("removed\t{name}")); } else { out.success(&format!("Removed peer: {name}"))?; } } else if out.is_porcelain() { - println!("not_found\t{name}"); + out.detail_porcelain(&format!("not_found\t{name}")); } else { out.warning(&format!("Peer not found: {name}"))?; } @@ -2287,7 +2315,7 @@ pub(crate) fn info(out: Output) -> eyre::Result<()> { let iroh_node_id_str: Option = None; if out.is_porcelain() { - info_porcelain(&config_dir, &peer_id_str, iroh_node_id_str.as_deref()); + info_porcelain(out, &config_dir, &peer_id_str, iroh_node_id_str.as_deref()); return Ok(()); } @@ -2295,11 +2323,16 @@ pub(crate) fn info(out: Output) -> eyre::Result<()> { } /// Porcelain output for `darn info`. -fn info_porcelain(config_dir: &Path, peer_id_str: &str, iroh_node_id_str: Option<&str>) { - println!("config_dir\t{}", config_dir.display()); - println!("peer_id\t{peer_id_str}"); +fn info_porcelain( + out: Output, + config_dir: &Path, + peer_id_str: &str, + iroh_node_id_str: Option<&str>, +) { + out.detail_porcelain(&format!("config_dir\t{}", config_dir.display())); + out.detail_porcelain(&format!("peer_id\t{peer_id_str}")); if let Some(iroh_id) = iroh_node_id_str { - println!("iroh_node_id\t{iroh_id}"); + out.detail_porcelain(&format!("iroh_node_id\t{iroh_id}")); } // Peers @@ -2311,10 +2344,10 @@ fn info_porcelain(config_dir: &Path, peer_id_str: &str, iroh_node_id_str: Option } else { "discovery".to_string() }; - println!( + out.detail_porcelain(&format!( "peer\t{}\t{}\t{mode}\t{peer_id_display}", peer.name, peer.address - ); + )); } } @@ -2327,9 +2360,9 @@ fn info_porcelain(config_dir: &Path, peer_id_str: &str, iroh_node_id_str: Option ); let file_count = manifest.as_ref().map(Manifest::len).unwrap_or(0); - println!("workspace_root\t{}", darn.root().display()); - println!("root_dir_id\t{root_id_str}"); - println!("tracked_files\t{file_count}"); + out.detail_porcelain(&format!("workspace_root\t{}", darn.root().display())); + out.detail_porcelain(&format!("root_dir_id\t{root_id_str}")); + out.detail_porcelain(&format!("tracked_files\t{file_count}")); if let Ok(manifest) = manifest { for entry in manifest.iter() { @@ -2345,14 +2378,14 @@ fn info_porcelain(config_dir: &Path, peer_id_str: &str, iroh_node_id_str: Option "binary" }; let url = sedimentree_id_to_url(entry.sedimentree_id); - println!( + out.detail_porcelain(&format!( "file\t{}\t{type_str}\t{state_str}\t{url}", entry.relative_path.display() - ); + )); } } } else { - println!("workspace\tnone"); + out.detail_porcelain("workspace\tnone"); } } diff --git a/darn_cli/src/main.rs b/darn_cli/src/main.rs index d8f84b9..f3dfa0e 100644 --- a/darn_cli/src/main.rs +++ b/darn_cli/src/main.rs @@ -11,6 +11,7 @@ use std::time::Duration; use clap::{Parser, Subcommand}; use eyre::Result; +use output::Verbosity; use tracing_subscriber::{EnvFilter, fmt}; mod commands; @@ -34,15 +35,20 @@ async fn main() -> Result<()> { fmt().with_env_filter(filter).init(); let porcelain = cli.porcelain; - let out = output::Output::new(porcelain); + let verbosity = match (cli.silent, cli.quiet) { + (true, _) => Verbosity::Silent, + (_, true) => Verbosity::Quiet, + _ => Verbosity::Normal, + }; + let out = output::Output::new(porcelain, verbosity); - // Apply Catppuccin Mocha theme for all cliclack prompts (skip in porcelain mode) - if !porcelain { + // Apply Catppuccin Mocha theme for all cliclack prompts (skip in non-interactive modes) + if !out.is_non_interactive() { theme::apply(); } // Ensure signer exists before running commands - if !setup::ensure_signer(porcelain)? { + if !setup::ensure_signer(out)? { return Ok(()); } @@ -99,6 +105,14 @@ struct Cli { #[arg(long, global = true)] porcelain: bool, + /// Suppress spinners and per-item detail; show only final summaries and errors + #[arg(short, long, global = true)] + quiet: bool, + + /// Suppress all output except errors (printed to stderr) + #[arg(long, global = true)] + silent: bool, + #[command(subcommand)] command: Commands, } diff --git a/darn_cli/src/output.rs b/darn_cli/src/output.rs index 2a04a2f..d51c5bd 100644 --- a/darn_cli/src/output.rs +++ b/darn_cli/src/output.rs @@ -4,6 +4,35 @@ //! - Status lines: `status\t\t` //! - Data lines: `\t\t...` //! - No spinners, progress bars, ANSI colors, or box-drawing characters +//! +//! # Verbosity levels +//! +//! Independent of format (interactive vs porcelain), output volume is +//! controlled by [`Verbosity`]: +//! +//! | Level | Spinners | Detail | Summaries | Errors | Prompts | +//! |----------|----------|--------|-----------|--------|--------------| +//! | Normal | yes | yes | yes | yes | interactive | +//! | Quiet | no | no | yes | yes | auto-accept | +//! | Silent | no | no | no | stderr | auto-accept | +//! +//! When combined with `--porcelain`, the highest suppression wins: +//! `--porcelain --silent` produces no output at all (check exit code). + +/// How much output to produce. +/// +/// Ordered from most verbose to least. When multiple flags are set, +/// the highest suppression level wins. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Verbosity { + /// Full output (default). + Normal, + /// Suppress spinners, progress bars, and per-item detail. + /// Final summary lines and errors are still shown. + Quiet, + /// Suppress everything except errors (printed to stderr). + Silent, +} /// Output mode controller. /// @@ -12,30 +41,59 @@ #[derive(Debug, Clone, Copy)] pub(crate) struct Output { porcelain: bool, + verbosity: Verbosity, } impl Output { - pub(crate) const fn new(porcelain: bool) -> Self { - Self { porcelain } + pub(crate) const fn new(porcelain: bool, verbosity: Verbosity) -> Self { + Self { + porcelain, + verbosity, + } } pub(crate) const fn is_porcelain(self) -> bool { self.porcelain } + pub(crate) const fn is_quiet(self) -> bool { + matches!(self.verbosity, Verbosity::Quiet | Verbosity::Silent) + } + + pub(crate) const fn is_silent(self) -> bool { + matches!(self.verbosity, Verbosity::Silent) + } + + /// Whether non-interactive mode is active (porcelain, quiet, or silent). + pub(crate) const fn is_non_interactive(self) -> bool { + self.porcelain || self.is_quiet() + } + // -- Lifecycle (intro/outro) -- - /// Print a command header. In porcelain mode, this is a no-op. + /// Print a command header. + /// + /// Suppressed in quiet/silent modes and porcelain. pub(crate) fn intro(self, title: &str) -> eyre::Result<()> { - if !self.porcelain { + if !self.porcelain && !self.is_quiet() { cliclack::intro(title)?; } Ok(()) } - /// Print a command footer. In porcelain mode, this is a no-op. + /// Print a command footer. + /// + /// In quiet mode, printed as a plain summary line. + /// Suppressed in silent mode and porcelain. pub(crate) fn outro(self, msg: &str) -> eyre::Result<()> { - if !self.porcelain { + if self.is_silent() || self.porcelain { + return Ok(()); + } + if self.is_quiet() { + if !msg.is_empty() { + println!("{msg}"); + } + } else { cliclack::outro(msg)?; } Ok(()) @@ -43,7 +101,13 @@ impl Output { // -- Logging -- + /// Log a success message. + /// + /// Suppressed in quiet and silent modes. pub(crate) fn success(self, msg: &str) -> eyre::Result<()> { + if self.is_quiet() { + return Ok(()); + } if self.porcelain { println!("ok\t{msg}"); } else { @@ -52,7 +116,14 @@ impl Output { Ok(()) } + /// Log an error message. + /// + /// Always shown. In silent mode, printed to stderr. pub(crate) fn error(self, msg: &str) -> eyre::Result<()> { + if self.is_silent() { + eprintln!("error: {msg}"); + return Ok(()); + } if self.porcelain { println!("error\t{msg}"); } else { @@ -61,7 +132,17 @@ impl Output { Ok(()) } + /// Log a warning message. + /// + /// In silent mode, printed to stderr. Suppressed in quiet mode. pub(crate) fn warning(self, msg: &str) -> eyre::Result<()> { + if self.is_silent() { + eprintln!("warning: {msg}"); + return Ok(()); + } + if self.is_quiet() { + return Ok(()); + } if self.porcelain { println!("warning\t{msg}"); } else { @@ -70,7 +151,13 @@ impl Output { Ok(()) } + /// Log an informational message. + /// + /// Suppressed in quiet and silent modes. pub(crate) fn info(self, msg: &str) -> eyre::Result<()> { + if self.is_quiet() { + return Ok(()); + } if self.porcelain { println!("info\t{msg}"); } else { @@ -79,7 +166,13 @@ impl Output { Ok(()) } + /// Log a low-priority remark. + /// + /// Suppressed in quiet and silent modes. pub(crate) fn remark(self, msg: &str) -> eyre::Result<()> { + if self.is_quiet() { + return Ok(()); + } if self.porcelain { // Remarks are low-priority; still emit them for completeness println!("info\t{msg}"); @@ -89,11 +182,34 @@ impl Output { Ok(()) } + /// Log a final summary line. + /// + /// Visible in quiet mode (this is _the_ line quiet mode exists to show). + /// Suppressed only in silent mode. + pub(crate) fn summary(self, msg: &str) -> eyre::Result<()> { + if self.is_silent() { + return Ok(()); + } + if self.porcelain { + println!("ok\t{msg}"); + } else if self.is_quiet() { + println!("{msg}"); + } else { + cliclack::log::success(msg)?; + } + Ok(()) + } + // -- Structured data -- /// Print a tab-separated data line (porcelain) or a note block (human). + /// + /// Suppressed in quiet and silent modes. #[allow(dead_code)] pub(crate) fn note(self, title: &str, content: &str) -> eyre::Result<()> { + if self.is_quiet() { + return Ok(()); + } if self.porcelain { // Emit each line of content prefixed with the title as context for line in content.lines() { @@ -108,8 +224,13 @@ impl Output { Ok(()) } - /// Print a single key-value pair. In human mode, uses `cliclack::log::info`. + /// Print a single key-value pair. + /// + /// Suppressed in quiet and silent modes. pub(crate) fn kv(self, key: &str, value: &str) -> eyre::Result<()> { + if self.is_quiet() { + return Ok(()); + } if self.porcelain { println!("{key}\t{value}"); } else { @@ -119,8 +240,37 @@ impl Output { } /// Print a raw data line (porcelain only). No-op in human mode. + /// + /// Suppressed in silent mode. #[allow(dead_code)] pub(crate) fn data(self, line: &str) { + if self.is_silent() { + return; + } + if self.porcelain { + println!("{line}"); + } + } + + // -- Detail output (per-file streaming lines) -- + + /// Print a per-item detail line (e.g., file created/modified in watch). + /// + /// Suppressed in quiet and silent modes. + pub(crate) fn detail(self, line: &str) { + if self.is_quiet() { + return; + } + println!("{line}"); + } + + /// Print a per-item detail line in porcelain format. + /// + /// Suppressed in silent mode. + pub(crate) fn detail_porcelain(self, line: &str) { + if self.is_silent() { + return; + } if self.porcelain { println!("{line}"); } @@ -130,7 +280,12 @@ impl Output { /// Start a spinner (human) or print a status message (porcelain). /// Returns a `Spinner` handle that can be stopped. + /// + /// In quiet/silent modes, returns a no-op spinner. pub(crate) fn spinner(self, msg: &str) -> Spinner { + if self.is_quiet() { + return Spinner::Suppressed; + } if self.porcelain { println!("info\t{msg}"); Spinner::Porcelain @@ -144,7 +299,12 @@ impl Output { // -- Progress bars -- /// Start a progress bar (human) or return a no-op counter (porcelain). + /// + /// In quiet/silent modes, returns a no-op progress bar. pub(crate) fn progress(self, total: u64, msg: &str) -> Progress { + if self.is_quiet() { + return Progress::Suppressed; + } if self.porcelain { println!("progress\t{msg}\t{total}"); Progress::Porcelain @@ -157,9 +317,11 @@ impl Output { // -- Confirm -- - /// Ask a yes/no question. In porcelain mode, returns the default value. + /// Ask a yes/no question. + /// + /// In porcelain, quiet, or silent mode, returns the default value. pub(crate) fn confirm(self, question: &str, default: bool) -> eyre::Result { - if self.porcelain { + if self.is_non_interactive() { Ok(default) } else { Ok(cliclack::confirm(question) @@ -172,7 +334,8 @@ impl Output { /// Prompt the user to select from a list of options. /// - /// Each item is `(value, label, hint)`. In porcelain mode, returns the first item. + /// Each item is `(value, label, hint)`. + /// In porcelain, quiet, or silent mode, returns the first item. /// /// # Errors /// @@ -183,7 +346,7 @@ impl Output { prompt: &str, items: &[(T, &str, &str)], ) -> eyre::Result { - if self.porcelain || items.is_empty() { + if self.is_non_interactive() || items.is_empty() { return items .first() .map(|(v, _, _)| v.clone()) @@ -200,14 +363,16 @@ impl Output { // -- Text input -- - /// Prompt for text input. In porcelain mode, returns the default or empty string. + /// Prompt for text input. + /// + /// In porcelain, quiet, or silent mode, returns the default or empty string. pub(crate) fn input( self, prompt: &str, placeholder: &str, default: Option<&str>, ) -> eyre::Result { - if self.porcelain { + if self.is_non_interactive() { return Ok(default.unwrap_or("").to_string()); } @@ -220,10 +385,12 @@ impl Output { } } -/// Spinner abstraction: wraps `cliclack::ProgressBar` or is a no-op. +/// Spinner abstraction: wraps `cliclack::ProgressBar`, is a porcelain stub, +/// or is fully suppressed (quiet/silent). pub(crate) enum Spinner { Interactive(cliclack::ProgressBar), Porcelain, + Suppressed, } impl Spinner { @@ -231,13 +398,14 @@ impl Spinner { match self { Spinner::Interactive(s) => s.stop(msg), Spinner::Porcelain => println!("ok\t{msg}"), + Spinner::Suppressed => {} } } pub(crate) fn clear(&self) { match self { Spinner::Interactive(s) => s.clear(), - Spinner::Porcelain => {} + Spinner::Porcelain | Spinner::Suppressed => {} } } @@ -245,36 +413,38 @@ impl Spinner { pub(crate) fn set_message(&self, msg: impl std::fmt::Display) { match self { Spinner::Interactive(s) => s.set_message(msg), - Spinner::Porcelain => {} + Spinner::Porcelain | Spinner::Suppressed => {} } } } -/// Progress bar abstraction: wraps `cliclack::ProgressBar` or is a no-op. +/// Progress bar abstraction: wraps `cliclack::ProgressBar`, is a porcelain stub, +/// or is fully suppressed (quiet/silent). pub(crate) enum Progress { Interactive(cliclack::ProgressBar), Porcelain, + Suppressed, } impl Progress { pub(crate) fn inc(&self, n: u64) { match self { Progress::Interactive(pb) => pb.inc(n), - Progress::Porcelain => {} + Progress::Porcelain | Progress::Suppressed => {} } } pub(crate) fn set_message(&self, msg: impl std::fmt::Display) { match self { Progress::Interactive(pb) => pb.set_message(msg), - Progress::Porcelain => {} + Progress::Porcelain | Progress::Suppressed => {} } } pub(crate) fn set_length(&self, len: u64) { match self { Progress::Interactive(pb) => pb.set_length(len), - Progress::Porcelain => {} + Progress::Porcelain | Progress::Suppressed => {} } } @@ -282,6 +452,7 @@ impl Progress { match self { Progress::Interactive(pb) => pb.stop(msg), Progress::Porcelain => println!("ok\t{msg}"), + Progress::Suppressed => {} } } } diff --git a/darn_cli/src/setup.rs b/darn_cli/src/setup.rs index 8c30108..ce0c7e0 100644 --- a/darn_cli/src/setup.rs +++ b/darn_cli/src/setup.rs @@ -7,17 +7,20 @@ use std::io::IsTerminal; use darn_core::{config, signer}; use subduction_core::peer::id::PeerId; +use crate::output::Output; + /// Checks if first-run setup is needed and runs it interactively. /// /// Returns `Ok(true)` if setup was completed (or already existed), /// `Ok(false)` if the user declined setup. /// -/// In porcelain mode, auto-generates signer without prompting. +/// In non-interactive mode (porcelain, quiet, silent, or non-TTY), +/// auto-generates signer without prompting. /// /// # Errors /// /// Returns an error if signer generation fails. -pub(crate) fn ensure_signer(porcelain: bool) -> eyre::Result { +pub(crate) fn ensure_signer(out: Output) -> eyre::Result { if config::global_signer_exists() { return Ok(true); } @@ -25,25 +28,23 @@ pub(crate) fn ensure_signer(porcelain: bool) -> eyre::Result { let signer_dir = config::global_signer_dir()?; let key_path = signer_dir.join("signing_key.ed25519"); - // Non-interactive mode (porcelain or non-TTY): auto-generate signer - if porcelain || !std::io::stdin().is_terminal() { + // Non-interactive mode: auto-generate signer + if out.is_non_interactive() || !std::io::stdin().is_terminal() { let s = signer::generate_and_save(&signer_dir)?; let peer_id: PeerId = s.verifying_key().into(); let peer_id_str = bs58::encode(peer_id.as_bytes()).into_string(); - if porcelain { + if out.is_porcelain() { println!("signer_generated\t{}", key_path.display()); println!("peer_id\t{peer_id_str}"); - } else { - println!("No signer found. Generating Ed25519 keypair..."); - println!(" Location: {}", key_path.display()); - println!(" Peer ID: {peer_id_str}"); + } else if !out.is_silent() { + out.summary(&format!("Generated signer at {}", key_path.display()))?; } return Ok(true); } // Interactive mode: use cliclack prompts - cliclack::intro("Welcome to darn! 🪡🧦")?; + cliclack::intro("Welcome to darn!")?; cliclack::log::info(format!( "No signer found. darn needs to generate an Ed25519 keypair\n\ From 3d4ca3cd65b6c2487b75836df870975381865ca0 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 4 Mar 2026 17:54:33 -0800 Subject: [PATCH 3/6] Bump cliclack and defautl dist to immut str --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- darn_cli/Cargo.toml | 2 +- darn_core/Cargo.toml | 2 +- darn_core/src/attributes.rs | 30 ++++++++++++++++++++++++------ darn_core/src/dotfile.rs | 10 ++++++++-- darn_core/src/file.rs | 1 + 7 files changed, 38 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6efdd18..d21c134 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "darn_cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ "assert_cmd", "bs58", @@ -949,7 +949,7 @@ dependencies = [ [[package]] name = "darn_core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "automerge", "blake3", diff --git a/Cargo.toml b/Cargo.toml index 9b876f5..7605a87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ rust-version = "1.90" [workspace.dependencies] # Local crates -darn_core = { version = "0.4.0", path = "darn_core", default-features = false } +darn_core = { version = "0.5.0", path = "darn_core", default-features = false } assert_cmd = "2.0" automerge = "0.7.3" diff --git a/darn_cli/Cargo.toml b/darn_cli/Cargo.toml index 30b6866..e173982 100644 --- a/darn_cli/Cargo.toml +++ b/darn_cli/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "darn_cli" description = "CLI for darn - CRDT-backed filesystem management" -version = "0.4.0" +version = "0.5.0" authors.workspace = true categories.workspace = true diff --git a/darn_core/Cargo.toml b/darn_core/Cargo.toml index 1cefdcf..fe87048 100644 --- a/darn_core/Cargo.toml +++ b/darn_core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "darn_core" description = "Core library for darn - CRDT-backed filesystem management" -version = "0.4.0" +version = "0.5.0" authors.workspace = true categories.workspace = true diff --git a/darn_core/src/attributes.rs b/darn_core/src/attributes.rs index cda6869..3dbcb85 100644 --- a/darn_core/src/attributes.rs +++ b/darn_core/src/attributes.rs @@ -1,7 +1,7 @@ //! File attributes for `darn` workspaces. //! -//! Determines whether files should be treated as text (character-level CRDT) -//! or binary (last-writer-wins) based on patterns in the `.darn` config file. +//! Determines how files are stored in Automerge based on patterns in the +//! `.darn` config file. //! //! # `.darn` Config Example //! @@ -9,14 +9,16 @@ //! { //! "attributes": { //! "binary": ["*.lock", "*.min.js", "*.map"], +//! "immutable": ["dist/**"], //! "text": ["*.md"] //! } //! } //! ``` //! -//! Supported classifications: +//! Supported classifications (checked in this priority order): +//! - `immutable` — LWW string, no character merging (Automerge `ScalarValue::Str`) //! - `text` — Character-level CRDT merging (Automerge `Text`) -//! - `binary` — Last-writer-wins (Automerge `Bytes`) +//! - `binary` — Last-writer-wins binary (Automerge `Bytes`) use std::path::Path; @@ -50,10 +52,13 @@ const DEFAULT_BINARY_PATTERNS: &[&str] = &[ ]; /// Attribute matcher for a workspace. +#[allow(clippy::struct_field_names)] #[derive(Debug, Clone)] pub struct AttributeRules { /// Glob set for binary patterns. binary_globs: GlobSet, + /// Glob set for immutable text patterns. + immutable_globs: GlobSet, /// Glob set for text patterns. text_globs: GlobSet, } @@ -66,6 +71,7 @@ impl AttributeRules { /// Returns an error if the attribute patterns cannot be compiled. pub fn from_config(_root: &Path, config: &DarnConfig) -> Result { let mut binary_builder = GlobSetBuilder::new(); + let mut immutable_builder = GlobSetBuilder::new(); let mut text_builder = GlobSetBuilder::new(); // Add default binary patterns @@ -78,6 +84,11 @@ impl AttributeRules { binary_builder.add(Glob::new(pattern)?); } + // Add user-configured immutable patterns from .darn + for pattern in &config.attributes.immutable { + immutable_builder.add(Glob::new(pattern)?); + } + // Add user-configured text patterns from .darn for pattern in &config.attributes.text { text_builder.add(Glob::new(pattern)?); @@ -85,6 +96,7 @@ impl AttributeRules { Ok(Self { binary_globs: binary_builder.build()?, + immutable_globs: immutable_builder.build()?, text_globs: text_builder.build()?, }) } @@ -107,12 +119,17 @@ impl AttributeRules { /// Get the attribute for a file path. /// /// Returns `Some(FileType)` if an explicit rule matches, `None` for auto-detect. - /// Text patterns take precedence over binary (user overrides win). + /// Priority: immutable > text > binary (most specific user override wins). #[must_use] pub fn get_attribute(&self, path: &Path) -> Option { let path_str = path.to_string_lossy(); - // Check text patterns first (user overrides) + // Check immutable patterns first (highest user priority) + if self.immutable_globs.is_match(path_str.as_ref()) { + return Some(FileType::Immutable); + } + + // Check text patterns (user overrides) if self.text_globs.is_match(path_str.as_ref()) { return Some(FileType::Text); } @@ -150,6 +167,7 @@ impl Default for AttributeRules { Self { binary_globs: binary_builder.build().unwrap_or_else(|_| GlobSet::empty()), + immutable_globs: GlobSet::empty(), text_globs: GlobSet::empty(), } } diff --git a/darn_core/src/dotfile.rs b/darn_core/src/dotfile.rs index 22b639a..07a8917 100644 --- a/darn_core/src/dotfile.rs +++ b/darn_core/src/dotfile.rs @@ -13,6 +13,7 @@ //! "ignore": [".git/", "*.log"], //! "attributes": { //! "binary": ["*.lock", "*.min.js"], +//! "immutable": ["dist/**"], //! "text": ["*.md"] //! } //! } @@ -97,16 +98,20 @@ pub struct AttributeMap { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub binary: Vec, + /// Patterns for immutable text (LWW string, no character merging) files. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub immutable: Vec, + /// Patterns for text (character-level CRDT) files. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub text: Vec, } impl AttributeMap { - /// Returns `true` if both lists are empty. + /// Returns `true` if all lists are empty. #[must_use] pub const fn is_empty(&self) -> bool { - self.binary.is_empty() && self.text.is_empty() + self.binary.is_empty() && self.immutable.is_empty() && self.text.is_empty() } } @@ -219,6 +224,7 @@ fn default_ignore_patterns() -> Vec { fn default_attribute_map() -> AttributeMap { AttributeMap { binary: DEFAULT_BINARY.iter().map(|s| (*s).to_string()).collect(), + immutable: Vec::new(), text: Vec::new(), } } diff --git a/darn_core/src/file.rs b/darn_core/src/file.rs index b21a023..d059d66 100644 --- a/darn_core/src/file.rs +++ b/darn_core/src/file.rs @@ -737,6 +737,7 @@ mod tests { Vec::new(), AttributeMap { binary: Vec::new(), + immutable: Vec::new(), text: vec!["*.txt".to_string()], }, ); From 6f7a0c683efbbaa718d14315da4be463ab7ae195 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 4 Mar 2026 20:20:54 -0800 Subject: [PATCH 4/6] Parallel --- Cargo.lock | 1 + darn_cli/Cargo.toml | 1 + darn_cli/src/commands.rs | 251 ++++++++++++++++--- darn_cli/src/main.rs | 61 +++++ darn_core/src/atomic_write.rs | 10 +- darn_core/src/darn.rs | 63 +---- darn_core/src/doc_edit.rs | 446 ++++++++++++++++++++++++++++++++++ darn_core/src/lib.rs | 1 + 8 files changed, 730 insertions(+), 104 deletions(-) create mode 100644 darn_core/src/doc_edit.rs diff --git a/Cargo.lock b/Cargo.lock index d21c134..7c18c0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -945,6 +945,7 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "tungstenite", ] [[package]] diff --git a/darn_cli/Cargo.toml b/darn_cli/Cargo.toml index e173982..95f135f 100644 --- a/darn_cli/Cargo.toml +++ b/darn_cli/Cargo.toml @@ -45,6 +45,7 @@ tokio-util.workspace = true tokio.workspace = true tracing-subscriber.workspace = true tracing.workspace = true +tungstenite.workspace = true [dev-dependencies] assert_cmd.workspace = true diff --git a/darn_cli/src/commands.rs b/darn_cli/src/commands.rs index db36203..542f5f7 100644 --- a/darn_cli/src/commands.rs +++ b/darn_cli/src/commands.rs @@ -1721,52 +1721,52 @@ pub(crate) async fn watch( event = rx.recv() => { match event { Some(WatchEvent::FileModified(path)) => { - if processor.process(WatchEvent::FileModified(path.clone())) { - if !is_quiet { - if is_porcelain { - let kind = if manifest.get_by_path(&path).is_none() { "created" } else { "modified" }; - out.detail_porcelain(&format!("{kind}\t{}", path.display())); + if processor.process(WatchEvent::FileModified(path.clone())) + && !is_quiet + { + if is_porcelain { + let kind = if manifest.get_by_path(&path).is_none() { "created" } else { "modified" }; + out.detail_porcelain(&format!("{kind}\t{}", path.display())); + } else { + let is_new = manifest.get_by_path(&path).is_none(); + if is_new { + out.detail(&format!(" {} {}", green.apply_to("+"), path.display())); } else { - let is_new = manifest.get_by_path(&path).is_none(); - if is_new { - out.detail(&format!(" {} {}", green.apply_to("+"), path.display())); - } else { - out.detail(&format!(" {} {}", yellow.apply_to("M"), path.display())); - } + out.detail(&format!(" {} {}", yellow.apply_to("M"), path.display())); } } } } Some(WatchEvent::FileDeleted(path)) => { - if processor.process(WatchEvent::FileDeleted(path.clone())) { - if !is_quiet { - if is_porcelain { - out.detail_porcelain(&format!("deleted\t{}", path.display())); - } else { - out.detail(&format!(" {} {}", red.apply_to("-"), path.display())); - } + if processor.process(WatchEvent::FileDeleted(path.clone())) + && !is_quiet + { + if is_porcelain { + out.detail_porcelain(&format!("deleted\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", red.apply_to("-"), path.display())); } } } Some(WatchEvent::FileCreated(path)) => { - if processor.process(WatchEvent::FileCreated(path.clone())) { - if !is_quiet { - if is_porcelain { - out.detail_porcelain(&format!("created\t{}", path.display())); - } else { - out.detail(&format!(" {} {}", green.apply_to("+"), path.display())); - } + if processor.process(WatchEvent::FileCreated(path.clone())) + && !is_quiet + { + if is_porcelain { + out.detail_porcelain(&format!("created\t{}", path.display())); + } else { + out.detail(&format!(" {} {}", green.apply_to("+"), path.display())); } } } Some(WatchEvent::FileRenamed { from, to }) => { - if processor.process(WatchEvent::FileRenamed { from: from.clone(), to: to.clone() }) { - if !is_quiet { - if is_porcelain { - out.detail_porcelain(&format!("renamed\t{}\t{}", from.display(), to.display())); - } else { - out.detail(&format!(" {} {} -> {}", dim.apply_to("R"), from.display(), to.display())); - } + if processor.process(WatchEvent::FileRenamed { from: from.clone(), to: to.clone() }) + && !is_quiet + { + if is_porcelain { + out.detail_porcelain(&format!("renamed\t{}\t{}", from.display(), to.display())); + } else { + out.detail(&format!(" {} {} -> {}", dim.apply_to("R"), from.display(), to.display())); } } } @@ -2111,7 +2111,7 @@ pub(crate) fn peer_add( peer_id: Option, out: Output, ) -> eyre::Result<()> { - let darn = Darn::open_without_subduction(Path::new("."))?; + use darn_core::peer; // -- Name -- let name = match name { @@ -2120,7 +2120,7 @@ pub(crate) fn peer_add( }; let peer_name = PeerName::new(&name)?; - if darn.get_peer(&peer_name)?.is_some() { + if peer::get_peer(&peer_name)?.is_some() { out.error(&format!("Peer already exists: {name}"))?; return Ok(()); } @@ -2159,7 +2159,7 @@ pub(crate) fn peer_add( "(discovery)".to_string() }; - darn.add_peer(&peer)?; + peer::add_peer(&peer)?; info!(%name, %addr_display, "Added peer"); @@ -2213,8 +2213,7 @@ fn peer_add_interactive(out: Output) -> eyre::Result { /// List known peers. pub(crate) fn peer_list(out: Output) -> eyre::Result<()> { - let darn = Darn::open_without_subduction(Path::new("."))?; - let peers = darn.list_peers()?; + let peers = darn_core::peer::list_peers()?; info!("Listing peers"); @@ -2274,10 +2273,9 @@ pub(crate) fn peer_list(out: Output) -> eyre::Result<()> { /// Remove a peer. pub(crate) fn peer_remove(name: &str, out: Output) -> eyre::Result<()> { - let darn = Darn::open_without_subduction(Path::new("."))?; let peer_name = PeerName::new(name)?; - if darn.remove_peer(&peer_name)? { + if darn_core::peer::remove_peer(&peer_name)? { info!(%name, "Removed peer"); if out.is_porcelain() { out.detail_porcelain(&format!("removed\t{name}")); @@ -2547,6 +2545,181 @@ fn info_human_workspace(dim: &Style) -> eyre::Result<()> { Ok(()) } +/// Connect to all global peers for standalone document operations. +/// +/// Returns the number of successfully connected peers. +/// +/// # Errors +/// +/// Returns an error on signer loading or URI parsing failures. +async fn connect_global_peers( + subduction: &darn_core::subduction::DarnSubduction, + signer_dir: &std::path::Path, + peers: &[darn_core::peer::Peer], + timeout: std::time::Duration, +) -> eyre::Result { + use darn_core::{signer, subduction::DarnConnection}; + use subduction_websocket::tokio::{TimeoutTokio, client::TokioWebSocketClient}; + use tungstenite::http::Uri; + + let mut connected = 0; + + for peer in peers { + match &peer.address { + PeerAddress::WebSocket { url } => { + let uri: Uri = url.parse()?; + let peer_signer = signer::load(signer_dir)?; + match TokioWebSocketClient::new( + uri, + TimeoutTokio, + timeout, + peer_signer, + peer.audience, + ) + .await + { + Ok((authenticated, listener_fut, sender_fut)) => { + tokio::spawn(async move { drop(listener_fut.await) }); + tokio::spawn(async move { drop(sender_fut.await) }); + let authenticated = + authenticated.map(|c| DarnConnection::WebSocket(Box::new(c))); + if let Err(e) = subduction.register(authenticated).await { + info!(%e, peer = %peer.name, "Failed to register"); + continue; + } + connected += 1; + } + Err(e) => { + info!(%e, peer = %peer.name, "Connection failed"); + } + } + } + #[cfg(feature = "iroh")] + PeerAddress::Iroh { .. } => { + info!(peer = %peer.name, "Skipping Iroh peer for doc edit"); + } + } + } + + Ok(connected) +} + +/// Edit an Automerge document directly, without a workspace. +/// +/// Connects to global peers, syncs the target sedimentree, loads the document, +/// applies the edit operation, stores the changes, and syncs back. +pub(crate) async fn doc_edit( + doc_url: &str, + op: darn_core::doc_edit::EditOp, + create: bool, + out: Output, +) -> eyre::Result<()> { + use darn_core::{doc_edit::apply_edit, signer, subduction as sub}; + + out.intro("darn doc edit")?; + + let sed_id_bytes = parse_automerge_url(doc_url)?; + let sed_id = SedimentreeId::new(sed_id_bytes); + let timeout = std::time::Duration::from_secs(30); + + // Load global signer and storage, hydrate Subduction + let signer_dir = darn_core::config::global_signer_dir()?; + let signer = signer::load(&signer_dir)?; + let storage = sub::create_global_storage()?; + + let spinner = out.spinner("Loading storage..."); + let subduction = sub::hydrate(signer, storage).await?; + spinner.stop("Storage loaded"); + + // Connect to all global peers + let peers = darn_core::peer::list_peers()?; + if peers.is_empty() { + eyre::bail!("No peers configured. Use `darn peer add` first."); + } + + let spinner = out.spinner("Connecting to peers..."); + let connected = connect_global_peers(&subduction, &signer_dir, &peers, timeout).await?; + if connected == 0 { + spinner.stop("Failed to connect"); + eyre::bail!("Could not connect to any peers"); + } + spinner.stop(format!("Connected to {connected} peer(s)")); + + // The path used for --create initialization + let create_path = match &op { + darn_core::doc_edit::EditOp::Append { path, .. } + | darn_core::doc_edit::EditOp::Clear { path } => path.clone(), + }; + + + // Sync, load, edit, store, sync back + let spinner = out.spinner("Syncing document..."); + let sync_result = subduction.sync_all(sed_id, true, Some(timeout)).await?; + let total_received: usize = sync_result + .values() + .filter(|(success, _, _)| *success) + .map(|(_, stats, _)| stats.total_received()) + .sum(); + spinner.stop(format!("Synced (received {total_received} items)")); + + let spinner = out.spinner("Loading document..."); + let mut doc = match sedimentree::load_document(&subduction, sed_id).await? { + Some(doc) => { + spinner.stop("Document loaded"); + doc + } + None if create => { + spinner.stop("Document not found — creating"); + let doc = darn_core::doc_edit::create_with_empty_list(&create_path)?; + out.success("Created new document")?; + doc + } + None => { + spinner.stop("Document not found"); + eyre::bail!( + "document not found after sync: {doc_url}\n hint: use --create to create it" + ); + } + }; + + let changed = apply_edit(&mut doc, &op)?; + + if !changed { + out.remark("No changes needed")?; + out.outro("Done")?; + return Ok(()); + } + + let op_description = match &op { + darn_core::doc_edit::EditOp::Append { path, values } => { + format!("Appended {} value(s) to {path}", values.len()) + } + darn_core::doc_edit::EditOp::Clear { path } => format!("Cleared {path}"), + }; + out.success(&op_description)?; + + let spinner = out.spinner("Storing changes..."); + sedimentree::store_document(&subduction, sed_id, &mut doc).await?; + spinner.stop("Changes stored"); + + let spinner = out.spinner("Syncing changes to peers..."); + let sync_result = subduction.sync_all(sed_id, true, Some(timeout)).await?; + let total_sent: usize = sync_result + .values() + .filter(|(success, _, _)| *success) + .map(|(_, stats, _)| stats.total_sent()) + .sum(); + spinner.stop(format!("Synced (sent {total_sent} items)")); + + if out.is_porcelain() { + out.kv("doc", doc_url)?; + out.kv("changed", "true")?; + } + + out.outro("Done")?; + Ok(()) +} + /// Truncate a string to fit within a given width, adding "..." if truncated. fn truncate_str(s: &str, max_len: usize) -> String { if s.len() <= max_len { diff --git a/darn_cli/src/main.rs b/darn_cli/src/main.rs index f3dfa0e..a3dd090 100644 --- a/darn_cli/src/main.rs +++ b/darn_cli/src/main.rs @@ -89,6 +89,21 @@ async fn main() -> Result<()> { PeerCommands::List => commands::peer_list(out), PeerCommands::Remove { name } => commands::peer_remove(&name, out), }, + Commands::Doc { command } => match command { + DocCommands::Edit { + doc_url, + create, + operation, + } => { + let op = match operation { + DocEditOp::Append { path, values } => { + darn_core::doc_edit::EditOp::Append { path, values } + } + DocEditOp::Clear { path } => darn_core::doc_edit::EditOp::Clear { path }, + }; + commands::doc_edit(&doc_url, op, create, out).await + } + }, } } @@ -96,6 +111,7 @@ async fn main() -> Result<()> { #[derive(Debug, Parser)] #[command(name = "darn")] #[command(version, about, long_about = None)] +#[allow(clippy::struct_excessive_bools)] struct Cli { /// Enable verbose output #[arg(short, long, global = true)] @@ -211,6 +227,12 @@ enum Commands { #[command(subcommand)] command: PeerCommands, }, + + /// Operate on Automerge documents directly + Doc { + #[command(subcommand)] + command: DocCommands, + }, } #[derive(Debug, Subcommand)] @@ -248,6 +270,45 @@ enum PeerCommands { }, } +#[derive(Debug, Subcommand)] +enum DocCommands { + /// Edit an Automerge document by path + /// + /// Operates on any Automerge document stored in Subduction, without + /// requiring a workspace. Connects to global peers, syncs the target + /// document, applies the edit, and syncs back. + Edit { + /// Automerge URL of the document (e.g., `automerge:2u4x5b6JdSMDkyyMrQRzb8dreHhL`) + doc_url: String, + + /// Create the document if it doesn't exist (initializes the target path as an empty list) + #[arg(long)] + create: bool, + + #[command(subcommand)] + operation: DocEditOp, + }, +} + +#[derive(Debug, Subcommand)] +enum DocEditOp { + /// Append values to a list (idempotent — skips duplicates) + Append { + /// Dot-separated path to the target list (e.g., `modules`) + path: String, + + /// Values to append + #[arg(required = true, num_args = 1..)] + values: Vec, + }, + + /// Remove all elements from a list + Clear { + /// Dot-separated path to the target list (e.g., `modules`) + path: String, + }, +} + /// Parse a duration string like "5s", "1m", "500ms", or "0" (for zero duration). fn parse_duration(s: &str) -> Result { let s = s.trim(); diff --git a/darn_core/src/atomic_write.rs b/darn_core/src/atomic_write.rs index 04f184c..24c9c9d 100644 --- a/darn_core/src/atomic_write.rs +++ b/darn_core/src/atomic_write.rs @@ -6,6 +6,8 @@ use std::{io, path::Path}; +use getrandom::getrandom as fill_random; + /// Write `data` to `path` atomically. /// /// Creates a temporary file in the same directory, writes the data, then @@ -22,10 +24,12 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> { std::fs::create_dir_all(parent)?; } - // Unique temp name: include thread ID to avoid races in parallel tests - let tid = std::thread::current().id(); + // Unique temp name: random suffix to avoid collisions across threads and processes + let mut nonce = [0u8; 8]; + fill_random(&mut nonce).map_err(io::Error::other)?; + let nonce = u64::from_ne_bytes(nonce); let stem = path.file_name().and_then(|n| n.to_str()).unwrap_or("data"); - let temp_name = format!("{stem}.{tid:?}.tmp"); + let temp_name = format!("{stem}.{nonce:016x}.tmp"); let temp_path = path.with_file_name(temp_name); // Write to temp file diff --git a/darn_core/src/darn.rs b/darn_core/src/darn.rs index 1a706e7..b0fe867 100644 --- a/darn_core/src/darn.rs +++ b/darn_core/src/darn.rs @@ -41,7 +41,7 @@ use crate::{ DarnSubduction, SubductionInitError, }, sync_progress::{ApplyResult, SyncProgressEvent, SyncSummary}, - workspace::{WorkspaceId, WorkspaceLayout, WorkspaceRegistry, registry::WorkspaceEntry}, + workspace::{WorkspaceId, WorkspaceLayout}, }; use refresh_diff::RefreshDiff; @@ -115,24 +115,6 @@ impl Darn { // Create .darn marker file with default ignore/attribute patterns let config = DarnConfig::create(&root, id, root_directory_id)?; - // Register in global registry - let mut registry = WorkspaceRegistry::load()?; - registry.register( - id, - WorkspaceEntry { - original_path: root.clone(), - name: root - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("workspace") - .to_string(), - created_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()), - }, - ); - registry.save()?; - // Ensure global signer exists let signer_dir = config::global_signer_dir()?; let signer = signer::load_or_generate(&signer_dir)?; @@ -189,24 +171,6 @@ impl Darn { // Create .darn marker file with default ignore/attribute patterns let config = DarnConfig::create(&root, id, root_directory_id)?; - // Register in global registry - let mut registry = WorkspaceRegistry::load()?; - registry.register( - id, - WorkspaceEntry { - original_path: root.clone(), - name: root - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("workspace") - .to_string(), - created_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()), - }, - ); - registry.save()?; - // Ensure global signer exists let signer_dir = config::global_signer_dir()?; let signer = signer::load_or_generate(&signer_dir)?; @@ -240,31 +204,6 @@ impl Darn { let config = DarnConfig::load(&root)?; let layout = WorkspaceLayout::new(config.id)?; - // Auto-heal registry if workspace was moved - if let Ok(mut registry) = WorkspaceRegistry::load() { - let needs_update = registry - .get(config.id) - .is_none_or(|entry| entry.original_path != root); - - if needs_update { - registry.register( - config.id, - WorkspaceEntry { - original_path: root.clone(), - name: root - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("workspace") - .to_string(), - created_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()), - }, - ); - drop(registry.save()); - } - } - let signer = Self::load_signer_static()?; let storage = Self::storage_from_layout(&layout)?; let subduction = Box::pin(subduction::hydrate(signer, storage)).await?; diff --git a/darn_core/src/doc_edit.rs b/darn_core/src/doc_edit.rs new file mode 100644 index 0000000..3573c2f --- /dev/null +++ b/darn_core/src/doc_edit.rs @@ -0,0 +1,446 @@ +//! Generic Automerge document editing operations. +//! +//! Provides operations for manipulating arbitrary Automerge documents by path, +//! without knowledge of their schema. Used by `darn doc edit` to modify any +//! document stored in Subduction. +//! +//! # Supported Operations +//! +//! - **Append**: Push a string value to a list at a given path +//! - **Clear**: Remove all elements from a list at a given path +//! +//! # Examples +//! +//! ```text +//! darn doc edit automerge:XYZ append modules "automerge:ABC" "automerge:DEF" +//! darn doc edit automerge:XYZ clear modules +//! ``` + +use automerge::{Automerge, ObjType, ReadDoc, ScalarValue, Value, transaction::Transactable}; +use thiserror::Error; + +/// An edit operation to apply to a document. +#[derive(Debug, Clone)] +pub enum EditOp { + /// Append one or more string values to a list, skipping any already present. + Append { + /// Dot-separated path to the target list (e.g., `"modules"`). + path: String, + /// Values to append. + values: Vec, + }, + + /// Remove all elements from a list at the given path. + Clear { + /// Dot-separated path to the target list (e.g., `"modules"`). + path: String, + }, +} + +/// Apply an edit operation to an Automerge document. +/// +/// Returns `true` if the document was modified, `false` if no change was needed +/// (e.g., value already present for idempotent append). +/// +/// # Errors +/// +/// Returns an error if the path doesn't exist, points to the wrong type, +/// or the Automerge transaction fails. +pub fn apply_edit(doc: &mut Automerge, op: &EditOp) -> Result { + match op { + EditOp::Append { path, values } => append_to_list(doc, path, values), + EditOp::Clear { path } => clear_list(doc, path), + } +} + +/// Append string values to a list at the given path. +/// +/// The path is a dot-separated key sequence from the document root. +/// For example, `"modules"` navigates to `doc.modules`. +/// +/// Idempotent: values already in the list are skipped. All new values +/// are inserted in a single transaction. +fn append_to_list(doc: &mut Automerge, path: &str, values: &[String]) -> Result { + let list_id = navigate_to_list(doc, path)?; + + // Collect existing values for dedup + let length = doc.length(&list_id); + let mut existing: std::collections::HashSet = + std::collections::HashSet::with_capacity(length); + for i in 0..length { + if let Some((Value::Scalar(scalar), _)) = doc.get(&list_id, i)? + && let ScalarValue::Str(s) = scalar.as_ref() + { + existing.insert(s.to_string()); + } + } + + let new_values: Vec<&str> = values + .iter() + .filter(|v| !existing.contains(v.as_str())) + .map(String::as_str) + .collect(); + + if new_values.is_empty() { + return Ok(false); + } + + doc.transact::<_, _, EditError>(|tx| { + for (i, value) in new_values.iter().enumerate() { + tx.insert(&list_id, length + i, ScalarValue::Str((*value).into()))?; + } + Ok(()) + }) + .map_err(|failure| failure.error)?; + + Ok(true) +} + +/// Remove all elements from a list at the given path. +/// +/// The path is a dot-separated key sequence from the document root. +/// Returns `true` if elements were removed, `false` if the list was already empty. +fn clear_list(doc: &mut Automerge, path: &str) -> Result { + let list_id = navigate_to_list(doc, path)?; + let length = doc.length(&list_id); + + if length == 0 { + return Ok(false); + } + + doc.transact::<_, _, EditError>(|tx| { + // Delete from the end to avoid index shifting + for i in (0..length).rev() { + tx.delete(&list_id, i)?; + } + Ok(()) + }) + .map_err(|failure| failure.error)?; + + Ok(true) +} + +/// Navigate a dot-separated path to a list object, returning its `ObjId`. +fn navigate_to_list(doc: &Automerge, path: &str) -> Result { + let segments: Vec<&str> = path.split('.').collect(); + let mut current = automerge::ROOT; + + for (i, segment) in segments.iter().enumerate() { + let is_last = i == segments.len() - 1; + + match doc.get(¤t, *segment)? { + Some((Value::Object(obj_type), obj_id)) => { + if is_last { + if obj_type != ObjType::List { + return Err(EditError::NotAList { + path: path.to_string(), + actual: format!("{obj_type:?}"), + }); + } + current = obj_id; + } else if obj_type != ObjType::Map { + return Err(EditError::NotAMap { + segment: (*segment).to_string(), + path: path.to_string(), + }); + } else { + current = obj_id; + } + } + Some((Value::Scalar(_), _)) => { + return Err(EditError::NotAnObject { + segment: (*segment).to_string(), + path: path.to_string(), + }); + } + None => { + return Err(EditError::PathNotFound { + segment: (*segment).to_string(), + path: path.to_string(), + }); + } + } + } + + Ok(current) +} + +/// Create a new Automerge document with the given dot-separated path initialized as an empty list. +/// +/// Intermediate path segments are created as maps. For example, `"a.b.modules"` +/// produces `{ a: { b: { modules: [] } } }`. +/// +/// # Errors +/// +/// Returns an error if the Automerge transaction fails. +pub fn create_with_empty_list(path: &str) -> Result { + let mut doc = Automerge::new(); + let segments: Vec<&str> = path.split('.').collect(); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let mut current = automerge::ROOT; + for (i, segment) in segments.iter().enumerate() { + if i == segments.len() - 1 { + tx.put_object(¤t, *segment, ObjType::List)?; + } else { + current = tx.put_object(¤t, *segment, ObjType::Map)?; + } + } + Ok(()) + }) + .map_err(|failure| failure.error)?; + Ok(doc) +} + +/// Errors from document edit operations. +#[derive(Debug, Error)] +pub enum EditError { + /// Path segment not found in the document. + #[error("path segment '{segment}' not found (full path: {path})")] + PathNotFound { + /// The missing segment. + segment: String, + /// The full path being navigated. + path: String, + }, + + /// Path segment points to a scalar, not an object. + #[error("'{segment}' is a scalar value, not an object (full path: {path})")] + NotAnObject { + /// The segment that was a scalar. + segment: String, + /// The full path. + path: String, + }, + + /// Expected a map but found a different object type. + #[error("'{segment}' is not a map (full path: {path})")] + NotAMap { + /// The segment. + segment: String, + /// The full path. + path: String, + }, + + /// Target path points to a non-list object. + #[error("'{path}' is {actual}, not a list")] + NotAList { + /// The full path. + path: String, + /// The actual type found. + actual: String, + }, + + /// Automerge operation failed. + #[error(transparent)] + Automerge(#[from] automerge::AutomergeError), + + /// Transaction failed. + #[error("transaction failed: {0}")] + Transaction(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn append_to_empty_list() { + let mut doc = Automerge::new(); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + tx.put_object(automerge::ROOT, "modules", ObjType::List)?; + Ok(()) + }) + .expect("create list"); + + let op = EditOp::Append { + path: "modules".to_string(), + values: vec!["automerge:abc123".to_string()], + }; + + let changed = apply_edit(&mut doc, &op).expect("append"); + assert!(changed); + + let (_, list_id) = doc + .get(automerge::ROOT, "modules") + .expect("get") + .expect("modules exists"); + assert_eq!(doc.length(&list_id), 1); + } + + #[test] + fn append_multiple_values() { + let mut doc = Automerge::new(); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + tx.put_object(automerge::ROOT, "modules", ObjType::List)?; + Ok(()) + }) + .expect("create list"); + + let op = EditOp::Append { + path: "modules".to_string(), + values: vec![ + "automerge:aaa".to_string(), + "automerge:bbb".to_string(), + "automerge:ccc".to_string(), + ], + }; + + let changed = apply_edit(&mut doc, &op).expect("append"); + assert!(changed); + + let (_, list_id) = doc + .get(automerge::ROOT, "modules") + .expect("get") + .expect("modules exists"); + assert_eq!(doc.length(&list_id), 3); + } + + #[test] + fn append_deduplicates_within_batch() { + let mut doc = Automerge::new(); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let list = tx.put_object(automerge::ROOT, "modules", ObjType::List)?; + tx.insert(&list, 0, ScalarValue::Str("automerge:existing".into()))?; + Ok(()) + }) + .expect("create list with value"); + + let op = EditOp::Append { + path: "modules".to_string(), + values: vec![ + "automerge:existing".to_string(), + "automerge:new".to_string(), + ], + }; + + let changed = apply_edit(&mut doc, &op).expect("append"); + assert!(changed); + + let (_, list_id) = doc + .get(automerge::ROOT, "modules") + .expect("get") + .expect("modules exists"); + assert_eq!(doc.length(&list_id), 2); + } + + #[test] + fn append_is_idempotent() { + let mut doc = Automerge::new(); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let list = tx.put_object(automerge::ROOT, "modules", ObjType::List)?; + tx.insert(&list, 0, ScalarValue::Str("automerge:abc123".into()))?; + Ok(()) + }) + .expect("create list with value"); + + let op = EditOp::Append { + path: "modules".to_string(), + values: vec!["automerge:abc123".to_string()], + }; + + let changed = apply_edit(&mut doc, &op).expect("append"); + assert!(!changed, "should not modify when value already present"); + + let (_, list_id) = doc + .get(automerge::ROOT, "modules") + .expect("get") + .expect("modules exists"); + assert_eq!(doc.length(&list_id), 1); + } + + #[test] + fn append_to_nonexistent_path() { + let mut doc = Automerge::new(); + + let op = EditOp::Append { + path: "modules".to_string(), + values: vec!["automerge:abc123".to_string()], + }; + + let result = apply_edit(&mut doc, &op); + assert!(result.is_err()); + } + + #[test] + fn clear_populated_list() { + let mut doc = Automerge::new(); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let list = tx.put_object(automerge::ROOT, "modules", ObjType::List)?; + tx.insert(&list, 0, ScalarValue::Str("automerge:aaa".into()))?; + tx.insert(&list, 1, ScalarValue::Str("automerge:bbb".into()))?; + tx.insert(&list, 2, ScalarValue::Str("automerge:ccc".into()))?; + Ok(()) + }) + .expect("create list with values"); + + let op = EditOp::Clear { + path: "modules".to_string(), + }; + + let changed = apply_edit(&mut doc, &op).expect("clear"); + assert!(changed); + + let (_, list_id) = doc + .get(automerge::ROOT, "modules") + .expect("get") + .expect("modules exists"); + assert_eq!(doc.length(&list_id), 0); + } + + #[test] + fn clear_empty_list() { + let mut doc = Automerge::new(); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + tx.put_object(automerge::ROOT, "modules", ObjType::List)?; + Ok(()) + }) + .expect("create empty list"); + + let op = EditOp::Clear { + path: "modules".to_string(), + }; + + let changed = apply_edit(&mut doc, &op).expect("clear"); + assert!(!changed, "should report no change for already-empty list"); + } + + #[test] + fn clear_then_append() { + let mut doc = Automerge::new(); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let list = tx.put_object(automerge::ROOT, "modules", ObjType::List)?; + tx.insert(&list, 0, ScalarValue::Str("automerge:old".into()))?; + Ok(()) + }) + .expect("create list with old value"); + + apply_edit( + &mut doc, + &EditOp::Clear { + path: "modules".to_string(), + }, + ) + .expect("clear"); + + apply_edit( + &mut doc, + &EditOp::Append { + path: "modules".to_string(), + values: vec!["automerge:new".to_string()], + }, + ) + .expect("append"); + + let (_, list_id) = doc + .get(automerge::ROOT, "modules") + .expect("get") + .expect("modules exists"); + assert_eq!(doc.length(&list_id), 1); + + let (Value::Scalar(scalar), _) = doc.get(&list_id, 0).expect("get").expect("has item") + else { + panic!("expected scalar"); + }; + assert_eq!(scalar.to_str(), Some("automerge:new")); + } +} diff --git a/darn_core/src/lib.rs b/darn_core/src/lib.rs index 964f405..64ce9fd 100644 --- a/darn_core/src/lib.rs +++ b/darn_core/src/lib.rs @@ -36,6 +36,7 @@ pub mod config; pub mod darn; pub mod directory; pub mod discover; +pub mod doc_edit; pub mod dotfile; pub mod file; pub mod ignore; From d209d246100d12c6666b429e0535b0b2218f6c0d Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Thu, 5 Mar 2026 13:58:06 -0800 Subject: [PATCH 5/6] Per workspace peers --- darn_cli/src/commands.rs | 70 ++++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/darn_cli/src/commands.rs b/darn_cli/src/commands.rs index 542f5f7..51f50e5 100644 --- a/darn_cli/src/commands.rs +++ b/darn_cli/src/commands.rs @@ -2557,12 +2557,12 @@ async fn connect_global_peers( signer_dir: &std::path::Path, peers: &[darn_core::peer::Peer], timeout: std::time::Duration, -) -> eyre::Result { +) -> eyre::Result> { use darn_core::{signer, subduction::DarnConnection}; use subduction_websocket::tokio::{TimeoutTokio, client::TokioWebSocketClient}; use tungstenite::http::Uri; - let mut connected = 0; + let mut connected = Vec::new(); for peer in peers { match &peer.address { @@ -2581,13 +2581,14 @@ async fn connect_global_peers( Ok((authenticated, listener_fut, sender_fut)) => { tokio::spawn(async move { drop(listener_fut.await) }); tokio::spawn(async move { drop(sender_fut.await) }); + let peer_id = authenticated.peer_id(); let authenticated = authenticated.map(|c| DarnConnection::WebSocket(Box::new(c))); if let Err(e) = subduction.register(authenticated).await { info!(%e, peer = %peer.name, "Failed to register"); continue; } - connected += 1; + connected.push(peer_id); } Err(e) => { info!(%e, peer = %peer.name, "Connection failed"); @@ -2615,6 +2616,7 @@ pub(crate) async fn doc_edit( out: Output, ) -> eyre::Result<()> { use darn_core::{doc_edit::apply_edit, signer, subduction as sub}; + use subduction_core::connection::Connection; out.intro("darn doc edit")?; @@ -2638,12 +2640,12 @@ pub(crate) async fn doc_edit( } let spinner = out.spinner("Connecting to peers..."); - let connected = connect_global_peers(&subduction, &signer_dir, &peers, timeout).await?; - if connected == 0 { + let peer_ids = connect_global_peers(&subduction, &signer_dir, &peers, timeout).await?; + if peer_ids.is_empty() { spinner.stop("Failed to connect"); eyre::bail!("Could not connect to any peers"); } - spinner.stop(format!("Connected to {connected} peer(s)")); + spinner.stop(format!("Connected to {} peer(s)", peer_ids.len())); // The path used for --create initialization let create_path = match &op { @@ -2651,15 +2653,30 @@ pub(crate) async fn doc_edit( | darn_core::doc_edit::EditOp::Clear { path } => path.clone(), }; - - // Sync, load, edit, store, sync back + // Sync the specific sedimentree with each connected peer (fetch phase). + // Uses sync_with_peer (per-sedimentree, per-peer) — the same path that + // workspace sync uses — rather than full_sync/sync_all which time out + // when the peer has never seen the sedimentree. let spinner = out.spinner("Syncing document..."); - let sync_result = subduction.sync_all(sed_id, true, Some(timeout)).await?; - let total_received: usize = sync_result - .values() - .filter(|(success, _, _)| *success) - .map(|(_, stats, _)| stats.total_received()) - .sum(); + let mut total_received: usize = 0; + for peer_id in &peer_ids { + match subduction + .sync_with_peer(peer_id, sed_id, true, Some(timeout)) + .await + { + Ok((success, stats, errors)) => { + if success { + total_received += stats.total_received(); + } + for (conn, err) in &errors { + tracing::warn!("Sync error with {:?}: {err:?}", Connection::peer_id(conn)); + } + } + Err(e) => { + tracing::warn!("Failed to sync with peer {peer_id:?}: {e}"); + } + } + } spinner.stop(format!("Synced (received {total_received} items)")); let spinner = out.spinner("Loading document..."); @@ -2703,12 +2720,25 @@ pub(crate) async fn doc_edit( spinner.stop("Changes stored"); let spinner = out.spinner("Syncing changes to peers..."); - let sync_result = subduction.sync_all(sed_id, true, Some(timeout)).await?; - let total_sent: usize = sync_result - .values() - .filter(|(success, _, _)| *success) - .map(|(_, stats, _)| stats.total_sent()) - .sum(); + let mut total_sent: usize = 0; + for peer_id in &peer_ids { + match subduction + .sync_with_peer(peer_id, sed_id, true, Some(timeout)) + .await + { + Ok((success, stats, errors)) => { + if success { + total_sent += stats.total_sent(); + } + for (conn, err) in &errors { + tracing::warn!("Sync error with {:?}: {err:?}", Connection::peer_id(conn)); + } + } + Err(e) => { + tracing::warn!("Failed to sync with peer {peer_id:?}: {e}"); + } + } + } spinner.stop(format!("Synced (sent {total_sent} items)")); if out.is_porcelain() { From 1fa09fbecec1fe117e34f8e047a8092c81c68ef7 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Fri, 6 Mar 2026 14:25:33 -0800 Subject: [PATCH 6/6] Retain immutible content through file lifetime --- darn_cli/src/commands.rs | 100 ++++++------- darn_cli/src/output.rs | 43 ------ darn_cli/src/setup.rs | 4 +- darn_cli/tests/cli.rs | 15 +- darn_core/src/darn.rs | 38 ++--- darn_core/src/doc_edit.rs | 164 +++++++++------------ darn_core/src/dotfile.rs | 59 +++++++- darn_core/src/file.rs | 29 +++- darn_core/src/file/content.rs | 99 +++++++++++++ darn_core/src/file/file_type.rs | 45 ++++++ darn_core/src/file/metadata/permissions.rs | 18 --- darn_core/src/file/name.rs | 8 +- darn_core/src/ignore.rs | 26 ++-- darn_core/src/refresh.rs | 58 ++++++++ darn_core/src/staged_update.rs | 28 ++-- darn_core/src/watcher.rs | 55 ++----- darn_core/tests/integration.rs | 37 ++--- 17 files changed, 485 insertions(+), 341 deletions(-) diff --git a/darn_cli/src/commands.rs b/darn_cli/src/commands.rs index 51f50e5..010a7e3 100644 --- a/darn_cli/src/commands.rs +++ b/darn_cli/src/commands.rs @@ -2370,10 +2370,10 @@ fn info_porcelain( FileState::Modified => "modified", FileState::Missing => "missing", }; - let type_str = if entry.file_type.is_text() { - "text" - } else { - "binary" + let type_str = match entry.file_type { + FileType::Text => "text", + FileType::Binary => "binary", + FileType::Immutable => "immutable", }; let url = sedimentree_id_to_url(entry.sedimentree_id); out.detail_porcelain(&format!( @@ -2519,10 +2519,10 @@ fn info_human_workspace(dim: &Style) -> eyre::Result<()> { FileState::Modified => "modified", FileState::Missing => "missing", }; - let type_str = if entry.file_type.is_text() { - "text" - } else { - "binary" + let type_str = match entry.file_type { + FileType::Text => "text", + FileType::Binary => "binary", + FileType::Immutable => "immut", }; files_table.push_str(&format!( "│ {:<40} │ {:^6} │ {:^19} │\n", @@ -2605,6 +2605,41 @@ async fn connect_global_peers( Ok(connected) } +/// Sync a single sedimentree with all connected peers, returning total items transferred. +async fn sync_sedimentree_with_peers( + subduction: &darn_core::subduction::DarnSubduction, + peer_ids: &[PeerId], + sed_id: SedimentreeId, + timeout: std::time::Duration, +) -> (usize, usize) { + use subduction_core::connection::Connection; + + let mut received = 0; + let mut sent = 0; + + for peer_id in peer_ids { + match subduction + .sync_with_peer(peer_id, sed_id, true, Some(timeout)) + .await + { + Ok((success, stats, errors)) => { + if success { + received += stats.total_received(); + sent += stats.total_sent(); + } + for (conn, err) in &errors { + tracing::warn!("Sync error with {:?}: {err:?}", Connection::peer_id(conn)); + } + } + Err(e) => { + tracing::warn!("Failed to sync with peer {peer_id:?}: {e}"); + } + } + } + + (received, sent) +} + /// Edit an Automerge document directly, without a workspace. /// /// Connects to global peers, syncs the target sedimentree, loads the document, @@ -2616,7 +2651,6 @@ pub(crate) async fn doc_edit( out: Output, ) -> eyre::Result<()> { use darn_core::{doc_edit::apply_edit, signer, subduction as sub}; - use subduction_core::connection::Connection; out.intro("darn doc edit")?; @@ -2653,30 +2687,10 @@ pub(crate) async fn doc_edit( | darn_core::doc_edit::EditOp::Clear { path } => path.clone(), }; - // Sync the specific sedimentree with each connected peer (fetch phase). - // Uses sync_with_peer (per-sedimentree, per-peer) — the same path that - // workspace sync uses — rather than full_sync/sync_all which time out - // when the peer has never seen the sedimentree. + // Fetch phase: sync sedimentree from peers let spinner = out.spinner("Syncing document..."); - let mut total_received: usize = 0; - for peer_id in &peer_ids { - match subduction - .sync_with_peer(peer_id, sed_id, true, Some(timeout)) - .await - { - Ok((success, stats, errors)) => { - if success { - total_received += stats.total_received(); - } - for (conn, err) in &errors { - tracing::warn!("Sync error with {:?}: {err:?}", Connection::peer_id(conn)); - } - } - Err(e) => { - tracing::warn!("Failed to sync with peer {peer_id:?}: {e}"); - } - } - } + let (total_received, _) = + sync_sedimentree_with_peers(&subduction, &peer_ids, sed_id, timeout).await; spinner.stop(format!("Synced (received {total_received} items)")); let spinner = out.spinner("Loading document..."); @@ -2719,26 +2733,10 @@ pub(crate) async fn doc_edit( sedimentree::store_document(&subduction, sed_id, &mut doc).await?; spinner.stop("Changes stored"); + // Push phase: sync changes back to peers let spinner = out.spinner("Syncing changes to peers..."); - let mut total_sent: usize = 0; - for peer_id in &peer_ids { - match subduction - .sync_with_peer(peer_id, sed_id, true, Some(timeout)) - .await - { - Ok((success, stats, errors)) => { - if success { - total_sent += stats.total_sent(); - } - for (conn, err) in &errors { - tracing::warn!("Sync error with {:?}: {err:?}", Connection::peer_id(conn)); - } - } - Err(e) => { - tracing::warn!("Failed to sync with peer {peer_id:?}: {e}"); - } - } - } + let (_, total_sent) = + sync_sedimentree_with_peers(&subduction, &peer_ids, sed_id, timeout).await; spinner.stop(format!("Synced (sent {total_sent} items)")); if out.is_porcelain() { diff --git a/darn_cli/src/output.rs b/darn_cli/src/output.rs index d51c5bd..f27f9ab 100644 --- a/darn_cli/src/output.rs +++ b/darn_cli/src/output.rs @@ -202,28 +202,6 @@ impl Output { // -- Structured data -- - /// Print a tab-separated data line (porcelain) or a note block (human). - /// - /// Suppressed in quiet and silent modes. - #[allow(dead_code)] - pub(crate) fn note(self, title: &str, content: &str) -> eyre::Result<()> { - if self.is_quiet() { - return Ok(()); - } - if self.porcelain { - // Emit each line of content prefixed with the title as context - for line in content.lines() { - let trimmed = line.trim(); - if !trimmed.is_empty() { - println!("{title}\t{trimmed}"); - } - } - } else { - cliclack::note(title, content)?; - } - Ok(()) - } - /// Print a single key-value pair. /// /// Suppressed in quiet and silent modes. @@ -239,19 +217,6 @@ impl Output { Ok(()) } - /// Print a raw data line (porcelain only). No-op in human mode. - /// - /// Suppressed in silent mode. - #[allow(dead_code)] - pub(crate) fn data(self, line: &str) { - if self.is_silent() { - return; - } - if self.porcelain { - println!("{line}"); - } - } - // -- Detail output (per-file streaming lines) -- /// Print a per-item detail line (e.g., file created/modified in watch). @@ -408,14 +373,6 @@ impl Spinner { Spinner::Porcelain | Spinner::Suppressed => {} } } - - #[allow(dead_code)] - pub(crate) fn set_message(&self, msg: impl std::fmt::Display) { - match self { - Spinner::Interactive(s) => s.set_message(msg), - Spinner::Porcelain | Spinner::Suppressed => {} - } - } } /// Progress bar abstraction: wraps `cliclack::ProgressBar`, is a porcelain stub, diff --git a/darn_cli/src/setup.rs b/darn_cli/src/setup.rs index ce0c7e0..385e18d 100644 --- a/darn_cli/src/setup.rs +++ b/darn_cli/src/setup.rs @@ -35,8 +35,8 @@ pub(crate) fn ensure_signer(out: Output) -> eyre::Result { let peer_id_str = bs58::encode(peer_id.as_bytes()).into_string(); if out.is_porcelain() { - println!("signer_generated\t{}", key_path.display()); - println!("peer_id\t{peer_id_str}"); + out.detail_porcelain(&format!("signer_generated\t{}", key_path.display())); + out.detail_porcelain(&format!("peer_id\t{peer_id_str}")); } else if !out.is_silent() { out.summary(&format!("Generated signer at {}", key_path.display()))?; } diff --git a/darn_cli/tests/cli.rs b/darn_cli/tests/cli.rs index b252453..23da0d3 100644 --- a/darn_cli/tests/cli.rs +++ b/darn_cli/tests/cli.rs @@ -12,6 +12,7 @@ use std::path::Path; use assert_cmd::cargo::cargo_bin_cmd; use predicates::prelude::*; +use testresult::TestResult; /// Test fixture: an isolated config dir + workspace dir. struct Fixture { @@ -215,15 +216,16 @@ fn sync_with_no_peers_succeeds_with_warning() { // ========================================================================== #[test] -fn sync_dry_run_no_peers() { +fn sync_dry_run_no_peers() -> TestResult { let f = Fixture::new(); f.init(); - std::fs::write(f.workspace().join("file.txt"), "content").expect("write file"); + std::fs::write(f.workspace().join("file.txt"), "content")?; // Dry-run with no peers exits early (success) — untracked files // don't appear in the manifest scan, and no peers means no sync plan. f.cmd().args(["sync", "--dry-run"]).assert().success(); + Ok(()) } // ========================================================================== @@ -264,13 +266,13 @@ fn unknown_command_fails() { // ========================================================================== #[test] -fn full_cli_init_and_tree() { +fn full_cli_init_and_tree() -> TestResult { let f = Fixture::new(); f.init(); - std::fs::write(f.workspace().join("hello.txt"), "hello").expect("write file"); - std::fs::create_dir_all(f.workspace().join("src")).expect("create dir"); - std::fs::write(f.workspace().join("src/lib.rs"), "pub fn hello() {}").expect("write file"); + std::fs::write(f.workspace().join("hello.txt"), "hello")?; + std::fs::create_dir_all(f.workspace().join("src"))?; + std::fs::write(f.workspace().join("src/lib.rs"), "pub fn hello() {}")?; f.cmd().args(["ignore", "*.log"]).assert().success(); @@ -287,4 +289,5 @@ fn full_cli_init_and_tree() { .assert() .success() .stdout(predicates::str::contains("workspace_root\t")); + Ok(()) } diff --git a/darn_core/src/darn.rs b/darn_core/src/darn.rs index b0fe867..9ae8b93 100644 --- a/darn_core/src/darn.rs +++ b/darn_core/src/darn.rs @@ -405,9 +405,10 @@ impl Darn { // Load attribute rules for consistent file type detection let attributes = AttributeRules::from_workspace_root(&self.root).ok(); - // Read current file content + // Read current file content, coercing to match the stored file type let current_fs_digest = content_hash::hash_file(&path)?; - let new_file = File::from_path_with_attributes(&path, attributes.as_ref())?; + let mut new_file = File::from_path_with_attributes(&path, attributes.as_ref())?; + new_file.content = new_file.content.coerce_to(entry.file_type); // Load existing Automerge doc from sedimentree let mut am_doc = sedimentree::load_document(&self.subduction, entry.sedimentree_id) @@ -464,6 +465,7 @@ impl Darn { FileState::Modified => modified.push(RefreshCandidate { path, sedimentree_id: entry.sedimentree_id, + file_type: entry.file_type, current_fs_digest: entry.file_system_digest, }), } @@ -537,7 +539,8 @@ impl Darn { } let attributes = AttributeRules::from_workspace_root(&self.root).ok(); - let new_file = File::from_path_with_attributes(&path, attributes.as_ref())?; + let mut new_file = File::from_path_with_attributes(&path, attributes.as_ref())?; + new_file.content = new_file.content.coerce_to(candidate.file_type); // Load existing doc let mut am_doc = sedimentree::load_document(&self.subduction, sed_id) @@ -562,31 +565,19 @@ impl Darn { })) } - /// Apply remote changes to local files after sync. - /// - /// For each tracked file, checks if the sedimentree digest changed (indicating - /// remote changes were received). If so, loads the merged CRDT document and - /// writes it to disk. - /// - /// Also discovers new files from the remote directory tree that aren't in - /// the local manifest. - /// - /// # Errors - /// - /// Individual file errors are collected in the result; this method doesn't - /// fail on individual file errors. /// Stage all remote changes for batch application to the workspace. /// - /// This is the slow "prepare" phase: loads CRDT documents, serializes - /// file content, and writes everything to a staging directory. No - /// workspace files are modified. - /// - /// Call [`StagedUpdate::commit`] to apply the changes. + /// This is the slow "prepare" phase: for each tracked file whose + /// sedimentree digest changed (indicating remote updates), loads the + /// merged CRDT document, serializes the content, and writes it to a + /// staging directory. Also discovers new files from the remote + /// directory tree that aren't in the local manifest. No workspace + /// files are modified until [`StagedUpdate::commit`] is called. /// /// # Errors /// - /// Returns errors from individual file operations in `ApplyResult`. - /// The `StagedUpdate` contains only the successfully staged operations. + /// Individual file errors are collected in [`ApplyResult`]; the + /// `StagedUpdate` contains only the successfully staged operations. #[allow(clippy::too_many_lines)] pub async fn stage_remote_changes( &self, @@ -1718,6 +1709,7 @@ impl UnopenedDarn { struct RefreshCandidate { path: PathBuf, sedimentree_id: SedimentreeId, + file_type: crate::file::file_type::FileType, current_fs_digest: sedimentree_core::crypto::digest::Digest, } diff --git a/darn_core/src/doc_edit.rs b/darn_core/src/doc_edit.rs index 3573c2f..7cdc482 100644 --- a/darn_core/src/doc_edit.rs +++ b/darn_core/src/doc_edit.rs @@ -233,48 +233,57 @@ pub enum EditError { /// Automerge operation failed. #[error(transparent)] Automerge(#[from] automerge::AutomergeError), - - /// Transaction failed. - #[error("transaction failed: {0}")] - Transaction(String), } #[cfg(test)] mod tests { use super::*; + use testresult::TestResult; - #[test] - fn append_to_empty_list() { + /// Helper: create an Automerge doc with a "modules" list, optionally pre-populated. + fn doc_with_list(initial: &[&str]) -> Result { let mut doc = Automerge::new(); doc.transact::<_, _, automerge::AutomergeError>(|tx| { - tx.put_object(automerge::ROOT, "modules", ObjType::List)?; + let list = tx.put_object(automerge::ROOT, "modules", ObjType::List)?; + for (i, val) in initial.iter().enumerate() { + tx.insert(&list, i, ScalarValue::Str((*val).into()))?; + } Ok(()) }) - .expect("create list"); + .map_err(|f| f.error)?; + Ok(doc) + } + + /// Helper: get the length of the "modules" list. + fn modules_len(doc: &Automerge) -> Result { + let (_, list_id) = doc + .get(automerge::ROOT, "modules")? + .ok_or_else(|| EditError::PathNotFound { + segment: "modules".into(), + path: "modules".into(), + })?; + Ok(doc.length(&list_id)) + } + + #[test] + fn append_to_empty_list() -> TestResult { + let mut doc = doc_with_list(&[])?; let op = EditOp::Append { path: "modules".to_string(), values: vec!["automerge:abc123".to_string()], }; - let changed = apply_edit(&mut doc, &op).expect("append"); + let changed = apply_edit(&mut doc, &op)?; assert!(changed); + assert_eq!(modules_len(&doc)?, 1); - let (_, list_id) = doc - .get(automerge::ROOT, "modules") - .expect("get") - .expect("modules exists"); - assert_eq!(doc.length(&list_id), 1); + Ok(()) } #[test] - fn append_multiple_values() { - let mut doc = Automerge::new(); - doc.transact::<_, _, automerge::AutomergeError>(|tx| { - tx.put_object(automerge::ROOT, "modules", ObjType::List)?; - Ok(()) - }) - .expect("create list"); + fn append_multiple_values() -> TestResult { + let mut doc = doc_with_list(&[])?; let op = EditOp::Append { path: "modules".to_string(), @@ -285,25 +294,16 @@ mod tests { ], }; - let changed = apply_edit(&mut doc, &op).expect("append"); + let changed = apply_edit(&mut doc, &op)?; assert!(changed); + assert_eq!(modules_len(&doc)?, 3); - let (_, list_id) = doc - .get(automerge::ROOT, "modules") - .expect("get") - .expect("modules exists"); - assert_eq!(doc.length(&list_id), 3); + Ok(()) } #[test] - fn append_deduplicates_within_batch() { - let mut doc = Automerge::new(); - doc.transact::<_, _, automerge::AutomergeError>(|tx| { - let list = tx.put_object(automerge::ROOT, "modules", ObjType::List)?; - tx.insert(&list, 0, ScalarValue::Str("automerge:existing".into()))?; - Ok(()) - }) - .expect("create list with value"); + fn append_deduplicates_within_batch() -> TestResult { + let mut doc = doc_with_list(&["automerge:existing"])?; let op = EditOp::Append { path: "modules".to_string(), @@ -313,39 +313,27 @@ mod tests { ], }; - let changed = apply_edit(&mut doc, &op).expect("append"); + let changed = apply_edit(&mut doc, &op)?; assert!(changed); + assert_eq!(modules_len(&doc)?, 2); - let (_, list_id) = doc - .get(automerge::ROOT, "modules") - .expect("get") - .expect("modules exists"); - assert_eq!(doc.length(&list_id), 2); + Ok(()) } #[test] - fn append_is_idempotent() { - let mut doc = Automerge::new(); - doc.transact::<_, _, automerge::AutomergeError>(|tx| { - let list = tx.put_object(automerge::ROOT, "modules", ObjType::List)?; - tx.insert(&list, 0, ScalarValue::Str("automerge:abc123".into()))?; - Ok(()) - }) - .expect("create list with value"); + fn append_is_idempotent() -> TestResult { + let mut doc = doc_with_list(&["automerge:abc123"])?; let op = EditOp::Append { path: "modules".to_string(), values: vec!["automerge:abc123".to_string()], }; - let changed = apply_edit(&mut doc, &op).expect("append"); + let changed = apply_edit(&mut doc, &op)?; assert!(!changed, "should not modify when value already present"); + assert_eq!(modules_len(&doc)?, 1); - let (_, list_id) = doc - .get(automerge::ROOT, "modules") - .expect("get") - .expect("modules exists"); - assert_eq!(doc.length(&list_id), 1); + Ok(()) } #[test] @@ -362,65 +350,44 @@ mod tests { } #[test] - fn clear_populated_list() { - let mut doc = Automerge::new(); - doc.transact::<_, _, automerge::AutomergeError>(|tx| { - let list = tx.put_object(automerge::ROOT, "modules", ObjType::List)?; - tx.insert(&list, 0, ScalarValue::Str("automerge:aaa".into()))?; - tx.insert(&list, 1, ScalarValue::Str("automerge:bbb".into()))?; - tx.insert(&list, 2, ScalarValue::Str("automerge:ccc".into()))?; - Ok(()) - }) - .expect("create list with values"); + fn clear_populated_list() -> TestResult { + let mut doc = doc_with_list(&["automerge:aaa", "automerge:bbb", "automerge:ccc"])?; let op = EditOp::Clear { path: "modules".to_string(), }; - let changed = apply_edit(&mut doc, &op).expect("clear"); + let changed = apply_edit(&mut doc, &op)?; assert!(changed); + assert_eq!(modules_len(&doc)?, 0); - let (_, list_id) = doc - .get(automerge::ROOT, "modules") - .expect("get") - .expect("modules exists"); - assert_eq!(doc.length(&list_id), 0); + Ok(()) } #[test] - fn clear_empty_list() { - let mut doc = Automerge::new(); - doc.transact::<_, _, automerge::AutomergeError>(|tx| { - tx.put_object(automerge::ROOT, "modules", ObjType::List)?; - Ok(()) - }) - .expect("create empty list"); + fn clear_empty_list() -> TestResult { + let mut doc = doc_with_list(&[])?; let op = EditOp::Clear { path: "modules".to_string(), }; - let changed = apply_edit(&mut doc, &op).expect("clear"); + let changed = apply_edit(&mut doc, &op)?; assert!(!changed, "should report no change for already-empty list"); + + Ok(()) } #[test] - fn clear_then_append() { - let mut doc = Automerge::new(); - doc.transact::<_, _, automerge::AutomergeError>(|tx| { - let list = tx.put_object(automerge::ROOT, "modules", ObjType::List)?; - tx.insert(&list, 0, ScalarValue::Str("automerge:old".into()))?; - Ok(()) - }) - .expect("create list with old value"); + fn clear_then_append() -> TestResult { + let mut doc = doc_with_list(&["automerge:old"])?; apply_edit( &mut doc, &EditOp::Clear { path: "modules".to_string(), }, - ) - .expect("clear"); + )?; apply_edit( &mut doc, @@ -428,19 +395,22 @@ mod tests { path: "modules".to_string(), values: vec!["automerge:new".to_string()], }, - ) - .expect("append"); + )?; + + assert_eq!(modules_len(&doc)?, 1); let (_, list_id) = doc - .get(automerge::ROOT, "modules") - .expect("get") - .expect("modules exists"); - assert_eq!(doc.length(&list_id), 1); + .get(automerge::ROOT, "modules")? + .ok_or("modules missing")?; - let (Value::Scalar(scalar), _) = doc.get(&list_id, 0).expect("get").expect("has item") + let (Value::Scalar(scalar), _) = doc + .get(&list_id, 0)? + .ok_or("first item missing")? else { - panic!("expected scalar"); + return Err("expected scalar".into()); }; assert_eq!(scalar.to_str(), Some("automerge:new")); + + Ok(()) } } diff --git a/darn_core/src/dotfile.rs b/darn_core/src/dotfile.rs index 07a8917..d29c303 100644 --- a/darn_core/src/dotfile.rs +++ b/darn_core/src/dotfile.rs @@ -246,7 +246,6 @@ pub enum DotfileError { Parse(serde_json::Error), } -#[allow(clippy::expect_used, clippy::panic)] #[cfg(test)] mod tests { use super::*; @@ -270,6 +269,59 @@ mod tests { Ok(()) } + #[test] + fn force_immutable_roundtrip() -> TestResult { + let dir = tempfile::tempdir()?; + let id = WorkspaceId::from_bytes([3; 16]); + let sed_id = SedimentreeId::new([4; 32]); + + let config = DarnConfig::with_fields( + id, + sed_id, + true, + vec![".git/".to_string()], + AttributeMap { + binary: Vec::new(), + immutable: Vec::new(), + text: Vec::new(), + }, + ); + config.save(dir.path())?; + + let loaded = DarnConfig::load(dir.path())?; + assert!( + loaded.force_immutable, + "force_immutable should survive roundtrip" + ); + + // Verify JSON contains the field + let json = std::fs::read_to_string(dir.path().join(DOTFILE_NAME))?; + assert!( + json.contains("\"force_immutable\": true"), + "JSON should contain force_immutable" + ); + + Ok(()) + } + + #[test] + fn force_immutable_false_omitted_from_json() -> TestResult { + let dir = tempfile::tempdir()?; + let id = WorkspaceId::from_bytes([5; 16]); + let sed_id = SedimentreeId::new([6; 32]); + + let config = DarnConfig::create(dir.path(), id, sed_id)?; + assert!(!config.force_immutable); + + let json = std::fs::read_to_string(dir.path().join(DOTFILE_NAME))?; + assert!( + !json.contains("force_immutable"), + "false should be omitted from JSON" + ); + + Ok(()) + } + #[test] fn find_root_finds_dotfile() -> TestResult { let dir = tempfile::tempdir()?; @@ -287,10 +339,11 @@ mod tests { } #[test] - fn find_root_not_found() { - let dir = tempfile::tempdir().expect("create tempdir"); + fn find_root_not_found() -> TestResult { + let dir = tempfile::tempdir()?; let result = DarnConfig::find_root(dir.path()); assert!(result.is_err()); + Ok(()) } #[test] diff --git a/darn_core/src/file.rs b/darn_core/src/file.rs index d059d66..c6454c0 100644 --- a/darn_core/src/file.rs +++ b/darn_core/src/file.rs @@ -177,7 +177,9 @@ impl File { if force_immutable { match detected { content::Content::Text(s) => content::Content::ImmutableString(s), - other => other, // Binary stays binary + content::Content::Bytes(_) | content::Content::ImmutableString(_) => { + detected + } } } else { detected @@ -342,7 +344,14 @@ impl File { automerge::ScalarValue::Str(smol_str) => { content::Content::ImmutableString(smol_str.to_string()) } - _ => { + automerge::ScalarValue::Int(_) + | automerge::ScalarValue::Uint(_) + | automerge::ScalarValue::F64(_) + | automerge::ScalarValue::Counter(_) + | automerge::ScalarValue::Timestamp(_) + | automerge::ScalarValue::Boolean(_) + | automerge::ScalarValue::Unknown { .. } + | automerge::ScalarValue::Null => { return Err(DeserializeError::InvalidSchema( "content must be Text, Str, or Bytes".into(), )); @@ -355,8 +364,9 @@ impl File { } }; - #[allow(clippy::wildcard_enum_match_arm)] // Read permissions from metadata.permissions (Patchwork convention) + #[allow(clippy::wildcard_enum_match_arm)] + // automerge::Value has many variants; we only care about Map let permissions = match doc.get(ROOT, "metadata")? { Some((automerge::Value::Object(ObjType::Map), metadata_id)) => { match doc.get(&metadata_id, "permissions")? { @@ -661,6 +671,19 @@ mod tests { }); } + #[allow(clippy::expect_used)] + #[test] + fn immutable_automerge_roundtrip() { + check!().with_type::().for_each(|text: &String| { + let doc = File::immutable("test.txt", text); + let am = doc.to_automerge().expect("to_automerge"); + let loaded = File::from_automerge(&am).expect("from_automerge"); + + assert_eq!(doc.name, loaded.name); + assert_eq!(doc.content, loaded.content); + }); + } + #[allow(clippy::expect_used)] #[test] fn permissions_automerge_roundtrip() { diff --git a/darn_core/src/file/content.rs b/darn_core/src/file/content.rs index 0c5e12a..b5797b7 100644 --- a/darn_core/src/file/content.rs +++ b/darn_core/src/file/content.rs @@ -69,6 +69,52 @@ impl Content { } } +impl Content { + /// Reinterprets this content to match the given [`FileType`]. + /// + /// This is used during refresh so that already-tracked files keep + /// their manifest file type rather than being re-detected from scratch. + /// The underlying data is unchanged — only the variant wrapper is adjusted. + /// + /// # Conversions + /// + /// | Content variant | Target type | Result | + /// |--------------------|-------------|----------------------------| + /// | `Text(s)` | `Immutable` | `ImmutableString(s)` | + /// | `ImmutableString(s)` | `Text` | `Text(s)` | + /// | `Text(s)` | `Binary` | `Bytes(s.into_bytes())` | + /// | `Bytes(b)` | `Text` | `Text(String::from_utf8_lossy)` | + /// | same kind | same kind | unchanged | + #[must_use] + pub fn coerce_to(self, target: FileType) -> Self { + match (self, target) { + // Already matching + (c @ Self::Text(_), FileType::Text) + | (c @ Self::Bytes(_), FileType::Binary) + | (c @ Self::ImmutableString(_), FileType::Immutable) => c, + + // Text ↔ ImmutableString (lossless, just changes merge strategy) + (Self::Text(s), FileType::Immutable) => Self::ImmutableString(s), + (Self::ImmutableString(s), FileType::Text) => Self::Text(s), + + // Text/ImmutableString → Binary + (Self::Text(s) | Self::ImmutableString(s), FileType::Binary) => { + Self::Bytes(s.into_bytes()) + } + + // Binary → Text/ImmutableString (lossy fallback — unlikely in practice) + (Self::Bytes(b), FileType::Text) => Self::Text( + String::from_utf8(b) + .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()), + ), + (Self::Bytes(b), FileType::Immutable) => Self::ImmutableString( + String::from_utf8(b) + .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()), + ), + } + } +} + impl From for FileType { fn from(c: Content) -> Self { match c { @@ -88,3 +134,56 @@ impl From<&Content> for FileType { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coerce_text_to_immutable() { + let content = Content::Text("hello".into()); + let coerced = content.coerce_to(FileType::Immutable); + assert_eq!(coerced, Content::ImmutableString("hello".into())); + } + + #[test] + fn coerce_immutable_to_text() { + let content = Content::ImmutableString("hello".into()); + let coerced = content.coerce_to(FileType::Text); + assert_eq!(coerced, Content::Text("hello".into())); + } + + #[test] + fn coerce_same_type_is_identity() { + let text = Content::Text("hello".into()); + assert_eq!(text.clone().coerce_to(FileType::Text), text); + + let bytes = Content::Bytes(vec![1, 2, 3]); + assert_eq!(bytes.clone().coerce_to(FileType::Binary), bytes); + + let immutable = Content::ImmutableString("hello".into()); + assert_eq!(immutable.clone().coerce_to(FileType::Immutable), immutable); + } + + #[test] + fn coerce_text_to_binary() { + let content = Content::Text("hello".into()); + let coerced = content.coerce_to(FileType::Binary); + assert_eq!(coerced, Content::Bytes(b"hello".to_vec())); + } + + #[test] + fn coerce_binary_to_immutable_valid_utf8() { + let content = Content::Bytes(b"hello".to_vec()); + let coerced = content.coerce_to(FileType::Immutable); + assert_eq!(coerced, Content::ImmutableString("hello".into())); + } + + #[test] + fn coerce_binary_to_text_invalid_utf8() { + let content = Content::Bytes(vec![0xFF, 0xFE]); + let coerced = content.coerce_to(FileType::Text); + // Should use lossy conversion, not panic + assert!(coerced.is_text()); + } +} diff --git a/darn_core/src/file/file_type.rs b/darn_core/src/file/file_type.rs index 8c51543..586e1ef 100644 --- a/darn_core/src/file/file_type.rs +++ b/darn_core/src/file/file_type.rs @@ -93,3 +93,48 @@ impl<'b, Ctx> minicbor::Decode<'b, Ctx> for FileType { } } } + +#[cfg(test)] +mod tests { + use super::*; + use testresult::TestResult; + + #[test] + fn serde_roundtrip() -> TestResult { + for variant in [FileType::Text, FileType::Binary, FileType::Immutable] { + let json = serde_json::to_string(&variant)?; + let loaded: FileType = serde_json::from_str(&json)?; + assert_eq!(variant, loaded, "serde roundtrip failed for {json}"); + } + Ok(()) + } + + #[test] + fn serde_names() -> TestResult { + assert_eq!(serde_json::to_string(&FileType::Text)?, "\"text\""); + assert_eq!(serde_json::to_string(&FileType::Binary)?, "\"binary\""); + assert_eq!( + serde_json::to_string(&FileType::Immutable)?, + "\"immutable\"" + ); + Ok(()) + } + + #[test] + fn minicbor_roundtrip() -> TestResult { + for variant in [FileType::Text, FileType::Binary, FileType::Immutable] { + let mut buf = Vec::new(); + minicbor::encode(variant, &mut buf)?; + let decoded: FileType = minicbor::decode(&buf)?; + assert_eq!(variant, decoded, "minicbor roundtrip failed for {variant}"); + } + Ok(()) + } + + #[test] + fn display_values() { + assert_eq!(FileType::Text.to_string(), "text/plain"); + assert_eq!(FileType::Binary.to_string(), "application/octet-stream"); + assert_eq!(FileType::Immutable.to_string(), "text/plain; immutable"); + } +} diff --git a/darn_core/src/file/metadata/permissions.rs b/darn_core/src/file/metadata/permissions.rs index 6d7542f..9479e28 100644 --- a/darn_core/src/file/metadata/permissions.rs +++ b/darn_core/src/file/metadata/permissions.rs @@ -246,17 +246,6 @@ mod tests { use super::*; use bolero::check; - #[test] - fn permission_set_bitwise_or() { - let rw = PermissionSet::from(Permission::Read) | Permission::Write; - assert!(rw.contains(Permission::Read)); - assert!(rw.contains(Permission::Write)); - assert!(!rw.contains(Permission::Execute)); - - let rwx = rw | Permission::Execute; - assert_eq!(rwx, PermissionSet::ALL); - } - #[test] fn permissions_mode_roundtrip() { check!().with_type::().for_each(|&bits| { @@ -274,11 +263,4 @@ mod tests { assert_eq!(Permissions::from_mode(0o777).rwx(), "rwxrwxrwx"); assert_eq!(Permissions::from_mode(0o000).rwx(), "---------"); } - - #[test] - fn permissions_is_executable() { - assert!(Permissions::from_mode(0o755).is_executable()); - assert!(Permissions::from_mode(0o100).is_executable()); - assert!(!Permissions::from_mode(0o644).is_executable()); - } } diff --git a/darn_core/src/file/name.rs b/darn_core/src/file/name.rs index 1e6e021..1f3c6a3 100644 --- a/darn_core/src/file/name.rs +++ b/darn_core/src/file/name.rs @@ -60,11 +60,11 @@ impl std::fmt::Display for Name { } } -#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] #[cfg(test)] mod tests { use super::*; use bolero::check; + use testresult::TestResult; #[test] fn rejects_path_separators() { @@ -88,9 +88,11 @@ mod tests { } #[test] - fn from_path_extracts_basename() { - let name = Name::from_path(Path::new("/some/path/to/file.rs")).unwrap(); + fn from_path_extracts_basename() -> TestResult { + let name = Name::from_path(Path::new("/some/path/to/file.rs")) + .ok_or("expected Some from valid path")?; assert_eq!(name.as_str(), "file.rs"); + Ok(()) } #[test] diff --git a/darn_core/src/ignore.rs b/darn_core/src/ignore.rs index d9777e1..2e8c68a 100644 --- a/darn_core/src/ignore.rs +++ b/darn_core/src/ignore.rs @@ -145,7 +145,6 @@ pub enum IgnoreMutateError { Dotfile(#[from] crate::dotfile::DotfileError), } -#[allow(clippy::expect_used, clippy::panic)] #[cfg(test)] mod tests { use super::*; @@ -155,7 +154,10 @@ mod tests { use testresult::TestResult; /// Create a `.darn` config file for testing. - fn create_test_config(root: &Path, ignore: Vec) { + fn create_test_config( + root: &Path, + ignore: Vec, + ) -> Result<(), crate::dotfile::DotfileError> { let id = WorkspaceId::from_bytes([1; 16]); let sed_id = SedimentreeId::new([2; 32]); let config = DarnConfig::with_fields( @@ -165,23 +167,25 @@ mod tests { ignore, crate::dotfile::AttributeMap::default(), ); - config.save(root).expect("save test config"); + config.save(root) } #[test] - fn darn_file_always_ignored() { - let dir = tempfile::tempdir().expect("create tempdir"); - create_test_config(dir.path(), Vec::new()); - let rules = IgnoreRules::from_workspace_root(dir.path()).expect("build rules"); + fn darn_file_always_ignored() -> TestResult { + let dir = tempfile::tempdir()?; + create_test_config(dir.path(), Vec::new())?; + let rules = IgnoreRules::from_workspace_root(dir.path())?; // .darn file should be ignored assert!(rules.is_ignored(Path::new(".darn"), false)); + Ok(()) } #[test] + #[allow(clippy::expect_used)] fn non_darn_paths_not_ignored_by_default() { let dir = tempfile::tempdir().expect("create tempdir"); - create_test_config(dir.path(), Vec::new()); + create_test_config(dir.path(), Vec::new()).expect("save test config"); let rules = IgnoreRules::from_workspace_root(dir.path()).expect("build rules"); check!().with_type::().for_each(|segment: &String| { @@ -206,7 +210,7 @@ mod tests { #[test] fn config_ignore_patterns_respected() -> TestResult { let dir = tempfile::tempdir()?; - create_test_config(dir.path(), vec!["*.log".to_string(), "target/".to_string()]); + create_test_config(dir.path(), vec!["*.log".to_string(), "target/".to_string()])?; let rules = IgnoreRules::from_workspace_root(dir.path())?; @@ -225,7 +229,7 @@ mod tests { create_test_config( dir.path(), vec!["*.log".to_string(), "!important.log".to_string()], - ); + )?; let rules = IgnoreRules::from_workspace_root(dir.path())?; @@ -250,7 +254,7 @@ mod tests { #[test] fn add_and_remove_pattern() -> TestResult { let dir = tempfile::tempdir()?; - create_test_config(dir.path(), Vec::new()); + create_test_config(dir.path(), Vec::new())?; // Add a pattern assert!(add_pattern(dir.path(), "*.log")?); diff --git a/darn_core/src/refresh.rs b/darn_core/src/refresh.rs index 65739d4..dd90c9c 100644 --- a/darn_core/src/refresh.rs +++ b/darn_core/src/refresh.rs @@ -114,6 +114,23 @@ mod tests { }); } + #[allow(clippy::expect_used)] + #[test] + fn update_immutable_string_content_roundtrip() { + check!() + .with_type::<(String, String)>() + .for_each(|(original, updated)| { + let doc = File::immutable("test.txt", original); + let mut am_doc = doc.to_automerge().expect("to_automerge"); + + let new_content = Content::ImmutableString(updated.clone()); + update_automerge_content(&mut am_doc, new_content).expect("update"); + + let loaded = File::from_automerge(&am_doc).expect("from_automerge"); + assert_eq!(loaded.content, Content::ImmutableString(updated.clone())); + }); + } + #[allow(clippy::expect_used)] #[test] fn update_binary_content_roundtrip() { @@ -130,4 +147,45 @@ mod tests { assert_eq!(loaded.content, Content::Bytes(updated.clone())); }); } + + /// Regression: refreshing an `ImmutableString` doc with `Text` content + /// (i.e., what `from_path_with_attributes` returns when `force_immutable` + /// is not passed) must fail — proving that the coercion in `darn.rs` is + /// necessary. Without `Content::coerce_to`, this exact scenario would + /// hit `InvalidDocument("content must be Text object")`. + #[allow(clippy::expect_used)] + #[test] + fn refresh_immutable_doc_with_text_content_fails_without_coercion() { + let doc = File::immutable("readme.txt", "original"); + let mut am_doc = doc.to_automerge().expect("to_automerge"); + + // Simulate what the refresh path would produce without coercion: + // disk file re-detected as Text instead of ImmutableString. + let mismatched = Content::Text("updated".into()); + let result = update_automerge_content(&mut am_doc, mismatched); + + assert!( + result.is_err(), + "Text content on an ImmutableString doc must fail" + ); + } + + /// Verify the fix: coercing `Text` → `ImmutableString` before refresh works. + #[allow(clippy::expect_used)] + #[test] + fn refresh_immutable_doc_with_coerced_content_succeeds() { + use crate::file::{content, file_type::FileType}; + + let doc = File::immutable("readme.txt", "original"); + let mut am_doc = doc.to_automerge().expect("to_automerge"); + + // Simulate the fixed refresh path: re-detected as Text, then coerced. + let redetected = content::Content::Text("updated".into()); + let coerced = redetected.coerce_to(FileType::Immutable); + + update_automerge_content(&mut am_doc, coerced).expect("coerced update should succeed"); + + let loaded = File::from_automerge(&am_doc).expect("from_automerge"); + assert_eq!(loaded.content, Content::ImmutableString("updated".into())); + } } diff --git a/darn_core/src/staged_update.rs b/darn_core/src/staged_update.rs index 6da133a..a37fae3 100644 --- a/darn_core/src/staged_update.rs +++ b/darn_core/src/staged_update.rs @@ -493,7 +493,7 @@ pub enum StageError { } #[cfg(test)] -#[allow(clippy::expect_used, clippy::indexing_slicing)] +#[allow(clippy::indexing_slicing)] mod tests { use super::*; use crate::manifest::content_hash; @@ -516,15 +516,13 @@ mod tests { let staged = StagedUpdate::new(workspace.path())?; assert!(staged.staging_dir.path().exists()); - assert!( - staged - .staging_dir - .path() - .file_name() - .expect("staging dir has name") - .to_string_lossy() - .starts_with(STAGING_DIR_PREFIX) - ); + let dir_name = staged + .staging_dir + .path() + .file_name() + .ok_or("staging dir should have a name")? + .to_string_lossy(); + assert!(dir_name.starts_with(STAGING_DIR_PREFIX)); Ok(()) } @@ -634,7 +632,6 @@ mod tests { } #[tokio::test] - #[allow(clippy::expect_used)] async fn commit_renames_files_into_workspace() -> TestResult { let workspace = tempfile::tempdir()?; let mut manifest = Manifest::new(); @@ -659,10 +656,9 @@ mod tests { assert_eq!(std::fs::read_to_string(ws_file)?, "hello world"); // Manifest should have the entry - assert!(manifest.get_by_path(Path::new("hello.txt")).is_some()); let tracked = manifest .get_by_path(Path::new("hello.txt")) - .expect("tracked"); + .ok_or("hello.txt should be tracked")?; assert_eq!(tracked.sedimentree_id, id); // Result should classify as created @@ -673,7 +669,6 @@ mod tests { } #[tokio::test] - #[allow(clippy::expect_used)] async fn commit_creates_parent_dirs() -> TestResult { let workspace = tempfile::tempdir()?; let mut manifest = Manifest::new(); @@ -753,7 +748,6 @@ mod tests { } #[tokio::test] - #[allow(clippy::expect_used)] async fn commit_mixed_creates_and_deletes() -> TestResult { let workspace = tempfile::tempdir()?; let mut manifest = Manifest::new(); @@ -799,7 +793,6 @@ mod tests { } #[tokio::test] - #[allow(clippy::expect_used)] async fn commit_binary_file() -> TestResult { let workspace = tempfile::tempdir()?; let mut manifest = Manifest::new(); @@ -825,14 +818,13 @@ mod tests { let tracked = manifest .get_by_path(Path::new("data.bin")) - .expect("tracked"); + .ok_or("data.bin should be tracked")?; assert_eq!(tracked.file_type, FileType::Binary); Ok(()) } #[tokio::test] - #[allow(clippy::expect_used)] async fn commit_overwrites_existing_file() -> TestResult { let workspace = tempfile::tempdir()?; let mut manifest = Manifest::new(); diff --git a/darn_core/src/watcher.rs b/darn_core/src/watcher.rs index 23fa4c1..08926c0 100644 --- a/darn_core/src/watcher.rs +++ b/darn_core/src/watcher.rs @@ -450,45 +450,18 @@ impl WatchProcessResult { } } -#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] #[cfg(test)] mod tests { use super::*; use crate::manifest::Manifest; + use testresult::TestResult; #[test] - fn watcher_config_default() { - let config = WatcherConfig::default(); - assert_eq!(config.debounce_duration, Duration::from_millis(300)); - assert!(config.auto_track); - assert!(config.auto_refresh); - } - - #[test] - fn watch_batch_is_empty() { - let batch = WatchBatch::default(); - assert!(batch.is_empty()); - assert_eq!(batch.len(), 0); - } - - #[test] - fn watch_batch_len() { - let batch = WatchBatch { - created: vec![PathBuf::from("a.txt")], - modified: vec![PathBuf::from("b.txt"), PathBuf::from("c.txt")], - deleted: vec![], - renamed: vec![(PathBuf::from("d.txt"), PathBuf::from("e.txt"))], - }; - assert!(!batch.is_empty()); - assert_eq!(batch.len(), 4); - } - - #[test] - fn event_processor_ignores_config_patterns() { + fn event_processor_ignores_config_patterns() -> TestResult { use crate::dotfile::{AttributeMap, DarnConfig}; use crate::workspace::WorkspaceId; - let temp_dir = tempfile::tempdir().expect("create tempdir"); + let temp_dir = tempfile::tempdir()?; let manifest = Manifest::new(); // Create .darn config with ignore pattern @@ -499,21 +472,21 @@ mod tests { vec!["*.log".to_string()], AttributeMap::default(), ); - config.save(temp_dir.path()).expect("save config"); + config.save(temp_dir.path())?; - let mut processor = - WatchEventProcessor::new(temp_dir.path(), &manifest).expect("create processor"); + let mut processor = WatchEventProcessor::new(temp_dir.path(), &manifest)?; // Should be ignored assert!(!processor.process(WatchEvent::FileModified(PathBuf::from("test.log")))); // Should not be ignored assert!(processor.process(WatchEvent::FileModified(PathBuf::from("test.txt")))); + Ok(()) } #[test] - fn event_processor_separates_created_and_modified() { - let temp_dir = tempfile::tempdir().expect("create tempdir"); + fn event_processor_separates_created_and_modified() -> TestResult { + let temp_dir = tempfile::tempdir()?; let mut manifest = Manifest::new(); // Add a tracked file @@ -526,8 +499,7 @@ mod tests { ); manifest.track(tracked); - let mut processor = - WatchEventProcessor::new(temp_dir.path(), &manifest).expect("create processor"); + let mut processor = WatchEventProcessor::new(temp_dir.path(), &manifest)?; // Modify tracked file processor.process(WatchEvent::FileModified(PathBuf::from("existing.txt"))); @@ -539,11 +511,12 @@ mod tests { assert_eq!(batch.created, vec![PathBuf::from("new.txt")]); assert_eq!(batch.modified, vec![PathBuf::from("existing.txt")]); + Ok(()) } #[test] - fn event_processor_handles_delete_restore_cycle() { - let temp_dir = tempfile::tempdir().expect("create tempdir"); + fn event_processor_handles_delete_restore_cycle() -> TestResult { + let temp_dir = tempfile::tempdir()?; let mut manifest = Manifest::new(); // Add a tracked file @@ -556,8 +529,7 @@ mod tests { ); manifest.track(tracked); - let mut processor = - WatchEventProcessor::new(temp_dir.path(), &manifest).expect("create processor"); + let mut processor = WatchEventProcessor::new(temp_dir.path(), &manifest)?; // Delete file processor.process(WatchEvent::FileDeleted(PathBuf::from("file.txt"))); @@ -571,5 +543,6 @@ mod tests { // Should be in modified, not deleted assert!(batch.deleted.is_empty()); assert_eq!(batch.modified, vec![PathBuf::from("file.txt")]); + Ok(()) } } diff --git a/darn_core/tests/integration.rs b/darn_core/tests/integration.rs index c8f54fe..d9aa930 100644 --- a/darn_core/tests/integration.rs +++ b/darn_core/tests/integration.rs @@ -387,12 +387,7 @@ async fn scan_discovers_new_files() -> TestResult { let names: Vec<_> = new_files .iter() - .map(|p| { - p.file_name() - .expect("has filename") - .to_string_lossy() - .to_string() - }) + .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) .collect(); assert!(names.contains(&"hello.txt".to_string())); assert!(names.contains(&"world.txt".to_string())); @@ -492,12 +487,12 @@ async fn ingest_and_track_files() -> TestResult { let readme = manifest .get_by_path(Path::new("readme.txt")) - .expect("readme tracked"); + .ok_or("readme should be tracked")?; assert_eq!(readme.state(env.workspace()), FileState::Clean); let data = manifest .get_by_path(Path::new("data.bin")) - .expect("data tracked"); + .ok_or("data.bin should be tracked")?; assert_eq!(data.state(env.workspace()), FileState::Clean); // Save and reload to verify persistence @@ -559,7 +554,7 @@ async fn refresh_detects_modified_file() -> TestResult { let entry = manifest .get_by_path(Path::new("file.txt")) - .expect("tracked"); + .ok_or("file.txt should be tracked")?; assert_eq!(entry.state(env.workspace()), FileState::Clean); // Modify the file @@ -567,7 +562,7 @@ async fn refresh_detects_modified_file() -> TestResult { let entry = manifest .get_by_path(Path::new("file.txt")) - .expect("tracked"); + .ok_or("file.txt should be tracked after modify")?; assert_eq!(entry.state(env.workspace()), FileState::Modified); // Refresh should pick it up @@ -578,7 +573,7 @@ async fn refresh_detects_modified_file() -> TestResult { // After refresh, should be clean again let entry = manifest .get_by_path(Path::new("file.txt")) - .expect("tracked"); + .ok_or("file.txt should be tracked after refresh")?; assert_eq!(entry.state(env.workspace()), FileState::Clean); Ok(()) @@ -603,7 +598,7 @@ async fn refresh_detects_missing_file() -> TestResult { let entry = manifest .get_by_path(Path::new("doomed.txt")) - .expect("tracked"); + .ok_or("doomed.txt should be tracked")?; assert_eq!(entry.state(env.workspace()), FileState::Missing); Ok(()) @@ -631,14 +626,12 @@ async fn staged_update_creates_files_atomically() -> TestResult { ]; for (path, content) in &files { - let file = darn_core::file::File::text( - Path::new(path) - .file_name() - .expect("file name") - .to_str() - .expect("utf8"), - *content, - ); + let name = Path::new(path) + .file_name() + .ok_or("path should have file name")? + .to_str() + .ok_or("file name should be utf8")?; + let file = darn_core::file::File::text(name, *content); let id = darn_core::generate_sedimentree_id(); let digest = sedimentree_core::crypto::digest::Digest::force_from_bytes([0u8; 32]); @@ -690,7 +683,7 @@ async fn staged_update_handles_mixed_creates_and_deletes() -> TestResult { let old_entry = manifest .get_by_path(Path::new("old.txt")) - .expect("old tracked"); + .ok_or("old.txt should be tracked")?; let old_id = old_entry.sedimentree_id; // Now stage: create a new file + delete the old one @@ -811,7 +804,7 @@ async fn full_local_workflow() -> TestResult { let readme = manifest .get_by_path(Path::new("README.md")) - .expect("readme tracked"); + .ok_or("README.md should be tracked")?; assert_eq!(readme.state(env.workspace()), FileState::Modified); // 8. Refresh