From 4a1aba24233515567a087e1822fcd197db69e0a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 04:15:42 +0000 Subject: [PATCH 1/4] core: named rows and one home per repeated rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure refactor, no behavior change. The wide tuple returns in db.rs (two of them wearing #[allow(clippy::type_complexity)]) become named row structs — ResourceListing, ResourceDetail, ResourceBacklinkRow, ResourceEdgeRow, PendingChunk, InboundEdge — and the inbound-edge queries stop fetching a src_id no consumer ever read. Rules and dances written more than once now live once: the meta reads behind the schema stamp and the recorded embedder (meta_value), the resolve-ref-then-locate opening of every note-addressed façade op (resolve_ref_to_path), the two copy-pasted halves of graph::neighbors (collect_neighbors), the fuse-then-resolve tail shared by the two search entry points (resolve_hits), the mtime stat read (ingest::unix_mtime), and the dot-hidden vault-membership rule that lived as three walk predicates plus validator prose (pathspec::is_hidden). move_note's by_file map drops the src_id it stored but never read, matching move_resource's shape. b2-embed gets the same treatment: files_present is THE installed check the loader, the provision fast path, and the settings picker all share, and embed_err is the one candle-to-core error map. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WE6yzYZhgzwrwYpzaYzMs2 --- crates/b2-core/src/db.rs | 254 ++++++++++++++++++------------- crates/b2-core/src/dirs.rs | 6 +- crates/b2-core/src/graph.rs | 80 +++++----- crates/b2-core/src/ingest.rs | 41 +++-- crates/b2-core/src/mv.rs | 67 ++++---- crates/b2-core/src/pathspec.rs | 14 ++ crates/b2-core/src/rm.rs | 16 +- crates/b2-core/src/search.rs | 32 ++-- crates/b2-core/src/vault.rs | 98 ++++++------ crates/b2-core/tests/write.rs | 2 +- crates/b2-embed/src/config.rs | 5 +- crates/b2-embed/src/model.rs | 36 +++-- crates/b2-embed/src/provision.rs | 4 +- 13 files changed, 343 insertions(+), 312 deletions(-) diff --git a/crates/b2-core/src/db.rs b/crates/b2-core/src/db.rs index 47d1ac0..4f328f4 100644 --- a/crates/b2-core/src/db.rs +++ b/crates/b2-core/src/db.rs @@ -363,18 +363,19 @@ fn schema_is_current(conn: &Connection) -> Result { Ok(stamped_version(conn)? == Some(SCHEMA_VERSION)) } +/// The value stored in `meta` under `key`, or `None` when unset. Callers must +/// know `meta` exists — every caller reads it past a check that implies it +/// (a table-presence check, or the embed pass having ensured the space). +fn meta_value(conn: &Connection, key: &str) -> Result> { + Ok(conn + .query_row("SELECT value FROM meta WHERE key = ?1", [key], |r| r.get(0)) + .optional()?) +} + /// The `schema_version` recorded in `meta`, or `None` on an index that has never been /// stamped — or whose stamp was lost, which [`apply_schema`] treats the same way. -/// Callers must know `meta` exists; its one caller reads it only past that check. fn stamped_version(conn: &Connection) -> Result> { - Ok(conn - .query_row( - "SELECT value FROM meta WHERE key = 'schema_version'", - [], - |r| r.get::<_, String>(0), - ) - .optional()? - .and_then(|s| s.parse().ok())) + Ok(meta_value(conn, "schema_version")?.and_then(|s| s.parse().ok())) } /// Create the schema and stamp `schema_version`, dropping whatever was there first. The @@ -669,16 +670,37 @@ pub fn resource_stat(conn: &Connection, path: &str) -> Result); -/// One `resource_detail` row: `(class, size, mtime, content_hash)`. -pub type ResourceDetail = (String, i64, Option, String); +/// One `list_resources` row — a resource's identity + stat for the file tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourceListing { + pub path: String, + pub class: String, + pub size: i64, + pub mtime: Option, +} + +/// One resource's full inventory row (`resource_detail`) — the fallback card's +/// metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourceDetail { + pub class: String, + pub size: i64, + pub mtime: Option, + pub content_hash: String, +} /// Every inventoried resource — [`ResourceListing`] rows, path-ordered — the /// file tree's resource half (`Vault::list_resources`, research §9b #10). pub fn list_resources(conn: &Connection) -> Result> { let mut stmt = conn.prepare("SELECT path, class, size, mtime FROM resources ORDER BY path")?; - let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?; + let rows = stmt.query_map([], |r| { + Ok(ResourceListing { + path: r.get(0)?, + class: r.get(1)?, + size: r.get(2)?, + mtime: r.get(3)?, + }) + })?; Ok(rows.collect::>>()?) } @@ -689,19 +711,34 @@ pub fn resource_detail(conn: &Connection, path: &str) -> Result, + pub r#type: String, + pub caption: Option, + pub embed: bool, +} + /// Every active edge pointing *at* the resource: the source note's identity plus /// the edge's `type`/`caption`/`embed` — the fallback card's backlinks panel, /// straight off the materialized graph. Ordered for deterministic display. -#[allow(clippy::type_complexity)] -pub fn inbound_resource_edges( - conn: &Connection, - path: &str, -) -> Result, String, Option, bool)>> { +pub fn inbound_resource_edges(conn: &Connection, path: &str) -> Result> { let mut stmt = conn.prepare( "SELECT e.src_id, n.path, n.title, e.type, e.caption, e.embed FROM edges e JOIN notes n ON n.b2id = e.src_id @@ -709,38 +746,36 @@ pub fn inbound_resource_edges( ORDER BY n.path, e.occurrence_index", )?; let rows = stmt.query_map([path], |r| { - Ok(( - r.get(0)?, - r.get(1)?, - r.get(2)?, - r.get(3)?, - r.get(4)?, - r.get::<_, i64>(5)? != 0, - )) + Ok(ResourceBacklinkRow { + src_b2id: r.get(0)?, + note_path: r.get(1)?, + note_title: r.get(2)?, + r#type: r.get(3)?, + caption: r.get(4)?, + embed: r.get::<_, i64>(5)? != 0, + }) })?; Ok(rows.collect::>>()?) } +/// One edge a note points *at a resource*, joined with the inventory's `class` +/// (the display glyph) — a row of `explain`'s file-links panel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourceEdgeRow { + pub path: String, + pub class: String, + pub r#type: String, + pub origin: String, + pub caption: Option, + pub embed: bool, + pub explanation: Option, +} + /// Every active edge a note points *at a resource* — the outbound complement of /// [`inbound_resource_edges`], so `explain` can present all three target kinds a /// note authors (note / resource / dangling — GH #22) instead of silently hiding -/// its file links. Joins the inventory for the resource's `class` (the display -/// glyph). Ordered for deterministic display. -#[allow(clippy::type_complexity)] -pub fn outbound_resource_edges( - conn: &Connection, - b2id: &str, -) -> Result< - Vec<( - String, - String, - String, - String, - Option, - bool, - Option, - )>, -> { +/// its file links. Ordered for deterministic display. +pub fn outbound_resource_edges(conn: &Connection, b2id: &str) -> Result> { let mut stmt = conn.prepare( "SELECT e.dst_resource_path, r.class, e.type, e.origin, e.caption, e.embed, e.explanation FROM edges e JOIN resources r ON r.path = e.dst_resource_path @@ -748,34 +783,35 @@ pub fn outbound_resource_edges( ORDER BY e.dst_resource_path, e.type, e.occurrence_index", )?; let rows = stmt.query_map([b2id], |r| { - Ok(( - r.get(0)?, - r.get(1)?, - r.get(2)?, - r.get(3)?, - r.get(4)?, - r.get::<_, i64>(5)? != 0, - r.get(6)?, - )) + Ok(ResourceEdgeRow { + path: r.get(0)?, + class: r.get(1)?, + r#type: r.get(2)?, + origin: r.get(3)?, + caption: r.get(4)?, + embed: r.get::<_, i64>(5)? != 0, + explanation: r.get(6)?, + }) })?; Ok(rows.collect::>>()?) } -/// The bounded inbound set a **resource move** must rewrite — for each active -/// edge at the resource, its source file and the exact authored link text -/// (`dst_path_raw`). The resource sibling of [`inbound_edge_targets`]; ordered -/// for deterministic rewriting. -pub fn inbound_resource_edge_targets( - conn: &Connection, - path: &str, -) -> Result> { +/// The bounded inbound set a **resource move** must rewrite — [`InboundEdge`] +/// rows. The resource sibling of [`inbound_edge_targets`]; ordered for +/// deterministic rewriting. +pub fn inbound_resource_edge_targets(conn: &Connection, path: &str) -> Result> { let mut stmt = conn.prepare( - "SELECT e.src_id, n.path, e.dst_path_raw + "SELECT n.path, e.dst_path_raw FROM edges e JOIN notes n ON n.b2id = e.src_id WHERE e.dst_resource_path = ?1 ORDER BY n.path, e.dst_path_raw", )?; - let rows = stmt.query_map([path], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; + let rows = stmt.query_map([path], |r| { + Ok(InboundEdge { + src_path: r.get(0)?, + dst_raw: r.get(1)?, + }) + })?; Ok(rows.collect::>>()?) } @@ -905,21 +941,8 @@ pub fn ensure_embedding_space(conn: &Connection, model_id: &str, dim: usize) -> /// under the write lock. Identity first: a recorded model that differs settles it /// without the `sqlite_master` lookup. fn embedding_space_matches(conn: &Connection, model_id: &str, dim: usize) -> Result { - let cur_model: Option = conn - .query_row( - "SELECT value FROM meta WHERE key = 'embed_model_id'", - [], - |r| r.get(0), - ) - .optional()?; - let cur_dim: Option = conn - .query_row("SELECT value FROM meta WHERE key = 'embed_dim'", [], |r| { - r.get(0) - }) - .optional()?; - - let unchanged = cur_model.as_deref() == Some(model_id) - && cur_dim.as_deref() == Some(dim.to_string().as_str()); + let unchanged = meta_value(conn, "embed_model_id")?.as_deref() == Some(model_id) + && meta_value(conn, "embed_dim")?.as_deref() == Some(dim.to_string().as_str()); Ok(unchanged && embedding_space_exists(conn)?) } @@ -928,18 +951,8 @@ fn embedding_space_matches(conn: &Connection, model_id: &str, dim: usize) -> Res /// the only place a model swap is detectable, so a read compares it to the active /// embedder and fails fast on a mismatch (index-engine.md §8). pub fn recorded_embedder(conn: &Connection) -> Result> { - let model: Option = conn - .query_row( - "SELECT value FROM meta WHERE key = 'embed_model_id'", - [], - |r| r.get(0), - ) - .optional()?; - let dim: Option = conn - .query_row("SELECT value FROM meta WHERE key = 'embed_dim'", [], |r| { - r.get(0) - }) - .optional()?; + let model = meta_value(conn, "embed_model_id")?; + let dim = meta_value(conn, "embed_dim")?; match (model, dim) { (Some(m), Some(d)) => Ok(Some((m, d.parse().unwrap_or(0)))), _ => Ok(None), @@ -985,12 +998,7 @@ pub fn note_for_chunk(conn: &Connection, chunk_id: i64) -> Result pub fn chunk_note_map(conn: &Connection) -> Result> { let mut stmt = conn.prepare("SELECT id, note_b2id FROM chunks")?; let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?; - let mut map = HashMap::new(); - for row in rows { - let (id, note) = row?; - map.insert(id, note); - } - Ok(map) + Ok(rows.collect::>>()?) } /// A chunk's text (None if the chunk id is unknown) — the search-hit → snippet @@ -1074,15 +1082,25 @@ pub fn embed_progress(conn: &Connection) -> Result<(usize, usize)> { Ok((embedded as usize, total as usize)) } -/// Every chunk still lacking a stored vector, as `(note_b2id, path, chunk_id, text)` -/// in `(path, seq)` order — the **DB-derived pending set** the embed pass fills +/// One chunk still lacking a stored vector — a row of the DB-derived pending set +/// ([`chunks_missing_vectors`]) the embed pass fills. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingChunk { + pub note_b2id: String, + pub note_path: String, + pub chunk_id: i64, + pub text: String, +} + +/// Every chunk still lacking a stored vector, in `(path, seq)` order — the +/// **DB-derived pending set** the embed pass fills /// (index-engine.md). Deriving it here is what decouples projection /// from embedding: nothing is handed between the two passes in memory, so any stop /// point (a cancelled embed, a crash between the passes) heals on the next embed. /// The ordering reproduces the fused reindex's per-note batching + progress. /// Generalizes [`note_fully_embedded`]; like it, requires the embedding space to /// exist — callers ensure it first. -pub fn chunks_missing_vectors(conn: &Connection) -> Result> { +pub fn chunks_missing_vectors(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT c.note_b2id, n.path, c.id, c.text FROM chunks c @@ -1091,7 +1109,14 @@ pub fn chunks_missing_vectors(conn: &Connection) -> Result>>()?) } @@ -1346,22 +1371,31 @@ pub fn resolve_b2id_to_path(conn: &Connection, b2id: &str) -> Result Result> { +pub fn inbound_edge_targets(conn: &Connection, dst_b2id: &str) -> Result> { let mut stmt = conn.prepare( - "SELECT e.src_id, n.path, e.dst_path_raw + "SELECT n.path, e.dst_path_raw FROM edges e JOIN notes n ON n.b2id = e.src_id WHERE e.dst_id = ?1 ORDER BY n.path, e.dst_path_raw", )?; - let rows = stmt.query_map([dst_b2id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; + let rows = stmt.query_map([dst_b2id], |r| { + Ok(InboundEdge { + src_path: r.get(0)?, + dst_raw: r.get(1)?, + }) + })?; Ok(rows.collect::>>()?) } diff --git a/crates/b2-core/src/dirs.rs b/crates/b2-core/src/dirs.rs index 7e8decc..11efd9d 100644 --- a/crates/b2-core/src/dirs.rs +++ b/crates/b2-core/src/dirs.rs @@ -41,11 +41,7 @@ fn collect_dirs(root: &Path, dir: &Path, out: &mut Vec) -> Result<()> { if !path.is_dir() { continue; } - let is_dotdir = path - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with('.')); - if is_dotdir { + if crate::pathspec::is_hidden(&path) { continue; } // `path` was produced by walking `root`, so `strip_prefix` cannot fail; diff --git a/crates/b2-core/src/graph.rs b/crates/b2-core/src/graph.rs index 0e12867..25e40e8 100644 --- a/crates/b2-core/src/graph.rs +++ b/crates/b2-core/src/graph.rs @@ -39,61 +39,51 @@ pub struct Neighbor { /// (others → this note), each labeled for display. Every edge is authored and active /// (there is no suggestion lifecycle), so this is the note's full typed graph. pub fn neighbors(conn: &Connection, b2id: &str) -> Result> { - let mut out = Vec::new(); - - let mut stmt = conn.prepare( + let mut out = collect_neighbors( + conn, "SELECT dst_id, type, explanation, origin FROM edges WHERE src_id = ?1 AND dst_id IS NOT NULL ORDER BY type, dst_id", + b2id, + Direction::Outbound, )?; - let rows = stmt.query_map([b2id], |r| { - Ok(( - r.get::<_, String>(0)?, - r.get::<_, String>(1)?, - r.get::<_, Option>(2)?, - r.get::<_, String>(3)?, - )) - })?; - for row in rows { - let (other, edge_type, explanation, origin) = row?; - let label = edge_type.clone(); - out.push(Neighbor { - other, - edge_type, - direction: Direction::Outbound, - label, - explanation, - origin, - }); - } - - let mut stmt = conn.prepare( + out.extend(collect_neighbors( + conn, "SELECT src_id, type, explanation, origin FROM edges WHERE dst_id = ?1 ORDER BY type, src_id", - )?; + b2id, + Direction::Inbound, + )?); + Ok(out) +} + +/// One direction's half of [`neighbors`]: run a query yielding +/// `(other, type, explanation, origin)` rows and label each for `direction` — +/// the verb itself outbound, its inverse inbound (data-model.md §2). +fn collect_neighbors( + conn: &Connection, + sql: &str, + b2id: &str, + direction: Direction, +) -> Result> { + let mut stmt = conn.prepare(sql)?; let rows = stmt.query_map([b2id], |r| { - Ok(( - r.get::<_, String>(0)?, - r.get::<_, String>(1)?, - r.get::<_, Option>(2)?, - r.get::<_, String>(3)?, - )) - })?; - for row in rows { - let (other, edge_type, explanation, origin) = row?; - let label = relation::inverse_label(&edge_type).to_string(); - out.push(Neighbor { - other, + let edge_type: String = r.get(1)?; + let label = match direction { + Direction::Outbound => edge_type.clone(), + Direction::Inbound => relation::inverse_label(&edge_type).to_string(), + }; + Ok(Neighbor { + other: r.get(0)?, edge_type, - direction: Direction::Inbound, + direction, label, - explanation, - origin, - }); - } - - Ok(out) + explanation: r.get(2)?, + origin: r.get(3)?, + }) + })?; + Ok(rows.collect::>>()?) } /// One outbound link that resolved to **nothing** — neither a note nor a resource diff --git a/crates/b2-core/src/ingest.rs b/crates/b2-core/src/ingest.rs index 5680528..3347601 100644 --- a/crates/b2-core/src/ingest.rs +++ b/crates/b2-core/src/ingest.rs @@ -40,6 +40,15 @@ use std::path::Path; /// sooner — another reason not to over-size it. const EMBED_BATCH: usize = 16; +/// A file's mtime as Unix seconds — the projection's shared stat reading +/// (notes, resources, and a moved resource's repoint all record the same +/// shape). `None` when the platform clock can't supply one. +pub(crate) fn unix_mtime(meta: &fs::Metadata) -> Option { + let modified = meta.modified().ok()?; + let since_epoch = modified.duration_since(std::time::UNIX_EPOCH).ok()?; + Some(since_epoch.as_secs() as i64) +} + /// Outcome of ingesting one file. #[derive(Debug, Clone)] pub struct Ingested { @@ -239,11 +248,7 @@ fn project_note_and_chunks( let body = parsed.body().to_string(); let body_hash = blake3::hash(body.as_bytes()).to_hex().to_string(); - let mtime = fs::metadata(&abs) - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64); + let mtime = fs::metadata(&abs).ok().as_ref().and_then(unix_mtime); // Decide the re-chunk BEFORE the upsert overwrites `body_hash`. The inline path // also reads vector state (its caller ensured the space, hence @@ -985,10 +990,12 @@ pub fn embed_vault( // One entry per pending note: `(b2id, path, that note's (chunk_id, text) pairs)`. type PendingNote = (String, String, Vec<(i64, String)>); let mut by_note: Vec = Vec::new(); - for (note_b2id, path, chunk_id, text) in db::chunks_missing_vectors(conn)? { + for c in db::chunks_missing_vectors(conn)? { match by_note.last_mut() { - Some((last, _, pending)) if *last == note_b2id => pending.push((chunk_id, text)), - _ => by_note.push((note_b2id, path, vec![(chunk_id, text)])), + Some((last, _, pending)) if *last == c.note_b2id => { + pending.push((c.chunk_id, c.text)); + } + _ => by_note.push((c.note_b2id, c.note_path, vec![(c.chunk_id, c.text)])), } } @@ -1194,11 +1201,7 @@ fn collect_vault_files( let entry = entry?; let path = entry.path(); if path.is_dir() { - let is_dotdir = path - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with('.')); - if !is_dotdir { + if !crate::pathspec::is_hidden(&path) { collect_vault_files(root, &path, notes, resources)?; } continue; @@ -1212,11 +1215,7 @@ fn collect_vault_files( match ResourceClass::of_path(&rel) { None => notes.push(rel), Some(class) => { - let is_dotfile = path - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with('.')); - if !is_dotfile { + if !crate::pathspec::is_hidden(&path) { resources.push((rel, class)); } } @@ -1260,11 +1259,7 @@ fn project_resources( } }; let size = meta.len() as i64; - let mtime = meta - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64); + let mtime = unix_mtime(&meta); if db::resource_stat(conn, rel)? == Some((size, mtime)) { indexed += 1; // unchanged — inventoried without touching the bytes continue; diff --git a/crates/b2-core/src/mv.rs b/crates/b2-core/src/mv.rs index b93d9d4..89f622d 100644 --- a/crates/b2-core/src/mv.rs +++ b/crates/b2-core/src/mv.rs @@ -87,25 +87,24 @@ pub fn move_note( // there. Group by file into a target→replacement map, preserving each link's // own `.md`-or-not convention (Obsidian omits `.md`; a stored `.md` is kept). let new_rel_no_md = new_rel.strip_suffix(".md").unwrap_or(&new_rel).to_string(); - let mut by_file: BTreeMap)> = BTreeMap::new(); - for (src_id, src_path, dst_raw) in db::inbound_edge_targets(conn, b2id)? { - let replacement = if dst_raw.ends_with(".md") { + let mut by_file: BTreeMap> = BTreeMap::new(); + for e in db::inbound_edge_targets(conn, b2id)? { + let replacement = if e.dst_raw.ends_with(".md") { new_rel.clone() } else { new_rel_no_md.clone() }; by_file - .entry(src_path) - .or_insert_with(|| (src_id, BTreeMap::new())) - .1 - .insert(dst_raw, replacement); + .entry(e.src_path) + .or_default() + .insert(e.dst_raw, replacement); } // 1. Markdown first: rewrite inbound link text in place. A self-link (the moved // note links to itself) is rewritten here at its old path, before the move. let mut rewrote = Vec::new(); let mut links_rewritten = 0usize; - for (src_path, (_src_id, targets)) in &by_file { + for (src_path, targets) in &by_file { let abs = vault_root.join(src_path); let raw = fs::read_to_string(&abs)?; let (new_raw, n) = rewrite_links(&raw, targets); @@ -200,14 +199,15 @@ pub fn move_resource( // (re-relativized against its note's directory), a vault-root target stays // vault-root; a `#fragment` suffix survives untouched. let mut by_file: BTreeMap> = BTreeMap::new(); - for (_src_id, src_path, dst_raw) in db::inbound_resource_edge_targets(conn, old_rel)? { - let src_dir = src_path + for e in db::inbound_resource_edge_targets(conn, old_rel)? { + let src_dir = e + .src_path .rsplit_once('/') .map(|(dir, _)| dir.to_string()) .unwrap_or_default(); - let (base, fragment) = match dst_raw.split_once('#') { + let (base, fragment) = match e.dst_raw.split_once('#') { Some((b, f)) => (b, Some(f)), - None => (dst_raw.as_str(), None), + None => (e.dst_raw.as_str(), None), }; let new_base = if base.trim() == old_rel { new_rel.clone() // authored vault-root — keep it vault-root @@ -219,9 +219,9 @@ pub fn move_resource( None => new_base, }; by_file - .entry(src_path) + .entry(e.src_path) .or_default() - .insert(dst_raw, replacement); + .insert(e.dst_raw, replacement); } // 1. Markdown first: rewrite inbound link text in place, both syntaxes. @@ -289,13 +289,12 @@ fn repoint_resource_row( new_rel: &str, new_abs: &Path, ) -> Result<()> { - let (_, size, _, content_hash) = db::resource_detail(conn, old_rel)? + let detail = db::resource_detail(conn, old_rel)? .ok_or_else(|| Error::ResourceNotFound(old_rel.to_string()))?; let mtime = fs::metadata(new_abs) .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64); + .as_ref() + .and_then(ingest::unix_mtime); let class = crate::resource::ResourceClass::of_path(new_rel) .map(|c| c.as_str().to_string()) .unwrap_or_else(|| "binary".to_string()); @@ -304,9 +303,9 @@ fn repoint_resource_row( &db::ResourceRow { path: new_rel, class: &class, - size, + size: detail.size, mtime, - content_hash: &content_hash, + content_hash: &detail.content_hash, }, )?; conn.execute("DELETE FROM resources WHERE path = ?1", [old_rel])?; @@ -415,35 +414,35 @@ pub fn move_dir( .strip_suffix(".md") .unwrap_or(&new_path) .to_string(); - for (_src_id, src_path, dst_raw) in db::inbound_edge_targets(conn, b2id)? { - let replacement = if dst_raw.ends_with(".md") { + for e in db::inbound_edge_targets(conn, b2id)? { + let replacement = if e.dst_raw.ends_with(".md") { new_path.clone() } else { new_path_no_md.clone() }; - if replacement != dst_raw { + if replacement != e.dst_raw { wiki_by_file - .entry(src_path) + .entry(e.src_path) .or_default() - .insert(dst_raw, replacement); + .insert(e.dst_raw, replacement); } } } for old_path in &moved_resources { let new_path = remap_prefix(old_path, &from, &to); - for (_src_id, src_path, dst_raw) in db::inbound_resource_edge_targets(conn, old_path)? { + for e in db::inbound_resource_edge_targets(conn, old_path)? { // The source's directory *after* the move — sources inside the moved // set remap; outside sources keep their dir. let src_dir_after = { - let src_after = remap_prefix(&src_path, &from, &to); + let src_after = remap_prefix(&e.src_path, &from, &to); src_after .rsplit_once('/') .map(|(dir, _)| dir.to_string()) .unwrap_or_default() }; - let (base, fragment) = match dst_raw.split_once('#') { + let (base, fragment) = match e.dst_raw.split_once('#') { Some((b, f)) => (b, Some(f)), - None => (dst_raw.as_str(), None), + None => (e.dst_raw.as_str(), None), }; let new_base = if base.trim() == old_path.as_str() { new_path.clone() // authored vault-root — keep it vault-root @@ -454,15 +453,15 @@ pub fn move_dir( Some(f) => format!("{new_base}#{f}"), None => new_base, }; - if replacement != dst_raw { + if replacement != e.dst_raw { wiki_by_file - .entry(src_path.clone()) + .entry(e.src_path.clone()) .or_default() - .insert(dst_raw.clone(), replacement.clone()); + .insert(e.dst_raw.clone(), replacement.clone()); md_by_file - .entry(src_path) + .entry(e.src_path) .or_default() - .insert(dst_raw, replacement); + .insert(e.dst_raw, replacement); } } } diff --git a/crates/b2-core/src/pathspec.rs b/crates/b2-core/src/pathspec.rs index 597c524..54604e1 100644 --- a/crates/b2-core/src/pathspec.rs +++ b/crates/b2-core/src/pathspec.rs @@ -3,6 +3,20 @@ //! have in common. Kept error-type-free (returns `Err(reason)` as a plain string) //! so each authoring op maps the reason onto its own [`crate::Error`] variant and //! its own user-facing phrasing, without the two coupling through a shared error. +//! +//! Also home to the one **vault-membership rule** ([`is_hidden`]) both walks and +//! the validators share: a dot-prefixed name is never vault material. + +/// Whether this walked entry's *name* is dot-prefixed — the vault-membership +/// rule the ingest walk and the folder walk both route on (`.b2/`, `.git/`, +/// `.DS_Store` are never vault material), and the same rule +/// [`normalize_rel_dir`] enforces on user input. One predicate so the walks +/// can't drift from each other or from the validator. +pub(crate) fn is_hidden(path: &std::path::Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with('.')) +} /// Normalize + validate `input` into a vault-relative path (any file kind). /// Trims, and switches backslashes to `/` (so the index stays in its one diff --git a/crates/b2-core/src/rm.rs b/crates/b2-core/src/rm.rs index dfbb9ba..7b1c08d 100644 --- a/crates/b2-core/src/rm.rs +++ b/crates/b2-core/src/rm.rs @@ -105,7 +105,7 @@ pub fn delete_note( // source is the note itself — it dies with the file, so it is not re-projected. let dangled: BTreeSet = db::inbound_edge_targets(conn, b2id)? .into_iter() - .map(|(_src_id, src_path, _raw)| src_path) + .map(|e| e.src_path) .filter(|p| p != rel) .collect(); @@ -134,7 +134,7 @@ pub fn delete_resource( db::resource_detail(conn, rel)?.ok_or_else(|| Error::ResourceNotFound(rel.to_string()))?; let dangled: BTreeSet = db::inbound_resource_edge_targets(conn, rel)? .into_iter() - .map(|(_src_id, src_path, _raw)| src_path) + .map(|e| e.src_path) .collect(); remove_file_if_present(&vault_root.join(rel))?; @@ -177,16 +177,16 @@ pub fn delete_dir( let prefix = format!("{dir}/"); let mut dangled: BTreeSet = BTreeSet::new(); for (b2id, _path) in ¬es { - for (_src_id, src_path, _raw) in db::inbound_edge_targets(conn, b2id)? { - if !src_path.starts_with(&prefix) { - dangled.insert(src_path); + for e in db::inbound_edge_targets(conn, b2id)? { + if !e.src_path.starts_with(&prefix) { + dangled.insert(e.src_path); } } } for path in &resources { - for (_src_id, src_path, _raw) in db::inbound_resource_edge_targets(conn, path)? { - if !src_path.starts_with(&prefix) { - dangled.insert(src_path); + for e in db::inbound_resource_edge_targets(conn, path)? { + if !e.src_path.starts_with(&prefix) { + dangled.insert(e.src_path); } } } diff --git a/crates/b2-core/src/search.rs b/crates/b2-core/src/search.rs index c0a1c51..7a247d8 100644 --- a/crates/b2-core/src/search.rs +++ b/crates/b2-core/src/search.rs @@ -102,24 +102,15 @@ pub fn keyword_only_search( query: &str, limit: usize, ) -> Result> { - let bm25 = keyword_search(conn, query, pool_size(limit))?; + let pool = pool_size(limit); + let bm25 = keyword_search(conn, query, pool)?; tracing::debug!( target: "b2::search", bm25_hits = bm25.len(), - pool = pool_size(limit), + pool, "keyword-only retrieval (no embedding space yet)" ); - let mut hits = Vec::new(); - for (chunk_id, score) in rrf_fuse(&[bm25], RRF_K).into_iter().take(limit) { - if let Some(note_b2id) = db::note_for_chunk(conn, chunk_id)? { - hits.push(Hit { - chunk_id, - note_b2id, - score, - }); - } - } - Ok(hits) + resolve_hits(conn, rrf_fuse(&[bm25], RRF_K), limit) } /// Hybrid search: BM25 ⊕ vector(query) → RRF → top `limit`, resolved to notes. @@ -143,8 +134,21 @@ pub fn hybrid_search( "hybrid retrieval fusing BM25 ⊕ vector via RRF" ); + resolve_hits(conn, rrf_fuse(&[bm25, vector], RRF_K), limit) +} + +/// The shared tail of [`keyword_only_search`] and [`hybrid_search`]: resolve the +/// fused `(chunk_id, score)` ranking to [`Hit`]s, best first, keeping the top +/// `limit` whose chunk still resolves to a note. Per-hit resolution is fine here — +/// the set is bounded by `limit` (contrast [`graph_filtered_search`], which walks +/// the full ranked space and needs the bulk map). +fn resolve_hits( + conn: &rusqlite::Connection, + fused: Vec<(i64, f64)>, + limit: usize, +) -> Result> { let mut hits = Vec::new(); - for (chunk_id, score) in rrf_fuse(&[bm25, vector], RRF_K).into_iter().take(limit) { + for (chunk_id, score) in fused.into_iter().take(limit) { if let Some(note_b2id) = db::note_for_chunk(conn, chunk_id)? { hits.push(Hit { chunk_id, diff --git a/crates/b2-core/src/vault.rs b/crates/b2-core/src/vault.rs index 0b324f9..cd174b6 100644 --- a/crates/b2-core/src/vault.rs +++ b/crates/b2-core/src/vault.rs @@ -686,17 +686,15 @@ impl Vault { fn resource_links_of(&self, b2id: &str) -> Result> { Ok(db::outbound_resource_edges(&self.conn, b2id)? .into_iter() - .map( - |(path, class, relation, origin, caption, embed, explanation)| ResourceLinkView { - path, - class, - relation, - origin, - caption, - embed, - explanation, - }, - ) + .map(|e| ResourceLinkView { + path: e.path, + class: e.class, + relation: e.r#type, + origin: e.origin, + caption: e.caption, + embed: e.embed, + explanation: e.explanation, + }) .collect()) } @@ -768,9 +766,7 @@ impl Vault { /// unknown ref. pub fn read(&self, note_ref: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "read", note = note_ref).entered(); - let b2id = self.resolve_ref(note_ref)?; - let path = db::resolve_b2id_to_path(&self.conn, &b2id)? - .ok_or_else(|| Error::NoteNotFound(note_ref.to_string()))?; + let (b2id, path) = self.resolve_ref_to_path(note_ref)?; let raw = fs::read_to_string(self.root.join(&path))?; let revision = revision_of(&raw); let parsed = note::parse(&raw); @@ -811,9 +807,7 @@ impl Vault { /// only an external write trips the guard ("last save wins — by construction"). pub fn write(&self, note_ref: &str, body: &str, base_revision: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "write", note = note_ref).entered(); - let b2id = self.resolve_ref(note_ref)?; - let path = db::resolve_b2id_to_path(&self.conn, &b2id)? - .ok_or_else(|| Error::NoteNotFound(note_ref.to_string()))?; + let (_, path) = self.resolve_ref_to_path(note_ref)?; let abs = self.root.join(&path); let raw = fs::read_to_string(&abs)?; @@ -875,9 +869,7 @@ impl Vault { ) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "write_frontmatter", note = note_ref) .entered(); - let b2id = self.resolve_ref(note_ref)?; - let path = db::resolve_b2id_to_path(&self.conn, &b2id)? - .ok_or_else(|| Error::NoteNotFound(note_ref.to_string()))?; + let (b2id, path) = self.resolve_ref_to_path(note_ref)?; let abs = self.root.join(&path); let raw = fs::read_to_string(&abs)?; @@ -943,11 +935,11 @@ impl Vault { let _op = tracing::debug_span!(target: "b2::vault", "list_resources").entered(); Ok(db::list_resources(&self.conn)? .into_iter() - .map(|(path, class, size, mtime)| ResourceSummary { - path, - class, - size, - mtime, + .map(|r| ResourceSummary { + path: r.path, + class: r.class, + size: r.size, + mtime: r.mtime, }) .collect()) } @@ -972,27 +964,25 @@ impl Vault { /// [`Error::ResourceNotFound`] when it is not inventoried. pub fn explain_resource(&self, path: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "explain_resource", path).entered(); - let (class, size, mtime, content_hash) = db::resource_detail(&self.conn, path)? + let detail = db::resource_detail(&self.conn, path)? .ok_or_else(|| Error::ResourceNotFound(path.to_string()))?; let backlinks = db::inbound_resource_edges(&self.conn, path)? .into_iter() - .map( - |(b2id, note_path, title, r#type, caption, embed)| ResourceBacklink { - b2id, - path: note_path, - title, - r#type, - caption, - embed, - }, - ) + .map(|b| ResourceBacklink { + b2id: b.src_b2id, + path: b.note_path, + title: b.note_title, + r#type: b.r#type, + caption: b.caption, + embed: b.embed, + }) .collect(); Ok(ResourceExplainView { path: path.to_string(), - class, - size, - mtime, - content_hash, + class: detail.class, + size: detail.size, + mtime: detail.mtime, + content_hash: detail.content_hash, backlinks, }) } @@ -1196,12 +1186,8 @@ impl Vault { if !relation::is_core(edge_type) { return Err(Error::InvalidRelation(edge_type.to_string())); } - let src_id = self.resolve_ref(src_ref)?; - let dst_id = self.resolve_ref(dst_ref)?; - let src_path = db::resolve_b2id_to_path(&self.conn, &src_id)? - .ok_or_else(|| Error::NoteNotFound(src_ref.to_string()))?; - let dst_full = db::resolve_b2id_to_path(&self.conn, &dst_id)? - .ok_or_else(|| Error::NoteNotFound(dst_ref.to_string()))?; + let (src_id, src_path) = self.resolve_ref_to_path(src_ref)?; + let (dst_id, dst_full) = self.resolve_ref_to_path(dst_ref)?; // The link path drops the `.md` Obsidian omits (matches how `[[links]]` are written). let dst_path = dst_full .strip_suffix(".md") @@ -1293,9 +1279,7 @@ impl Vault { /// `reindex`/`link`. pub fn move_note(&self, note_ref: &str, to: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "mv", from = note_ref, to).entered(); - let b2id = self.resolve_ref(note_ref)?; - let old_rel = db::resolve_b2id_to_path(&self.conn, &b2id)? - .ok_or_else(|| Error::NoteNotFound(note_ref.to_string()))?; + let (b2id, old_rel) = self.resolve_ref_to_path(note_ref)?; mv::move_note( &self.conn, &self.idgen, @@ -1318,9 +1302,7 @@ impl Vault { /// inbound re-projection touches no vectors and needs no model. pub fn delete_note(&self, note_ref: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "rm", note = note_ref).entered(); - let b2id = self.resolve_ref(note_ref)?; - let rel = db::resolve_b2id_to_path(&self.conn, &b2id)? - .ok_or_else(|| Error::NoteNotFound(note_ref.to_string()))?; + let (b2id, rel) = self.resolve_ref_to_path(note_ref)?; rm::delete_note( &self.conn, &self.idgen, @@ -1445,6 +1427,18 @@ impl Vault { db::resolve_link_target(&self.conn, note_ref)? .ok_or_else(|| Error::NoteNotFound(note_ref.to_string())) } + + /// [`resolve_ref`](Self::resolve_ref) plus the note's vault-relative path — + /// the opening dance of every note-addressed op that touches the file + /// (`read`/`write`/`link`/`move_note`/`delete_note`). The + /// [`Error::NoteNotFound`] carries the caller's original `note_ref`, so the + /// error reads as the user typed it. + fn resolve_ref_to_path(&self, note_ref: &str) -> Result<(String, String)> { + let b2id = self.resolve_ref(note_ref)?; + let path = db::resolve_b2id_to_path(&self.conn, &b2id)? + .ok_or_else(|| Error::NoteNotFound(note_ref.to_string()))?; + Ok((b2id, path)) + } } /// A file's save-guard revision: blake3 of its raw bytes. diff --git a/crates/b2-core/tests/write.rs b/crates/b2-core/tests/write.rs index b8fa00e..ea424af 100644 --- a/crates/b2-core/tests/write.rs +++ b/crates/b2-core/tests/write.rs @@ -135,7 +135,7 @@ fn write_reprojects_keyword_graph_and_clears_stale_vectors() { // an embed pass then fills exactly (§7 invariant 5 — convergence). let missing = db::chunks_missing_vectors(&conn).unwrap(); assert!(!missing.is_empty(), "saved chunks await embedding"); - assert!(missing.iter().all(|(_, path, _, _)| path == SRS_PATH)); + assert!(missing.iter().all(|c| c.note_path == SRS_PATH)); let embed = vault.embed(&mut |_| ControlFlow::Continue(())).unwrap(); assert_eq!(embed.embedded, 1, "the embed pass fills the saved note"); assert_eq!(count(&conn, "embeddings"), count(&conn, "chunks")); diff --git a/crates/b2-embed/src/config.rs b/crates/b2-embed/src/config.rs index 1045fa3..5b7f40e 100644 --- a/crates/b2-embed/src/config.rs +++ b/crates/b2-embed/src/config.rs @@ -198,10 +198,7 @@ impl EmbedConfig { /// choices are installed vs. still need `b2 init`. Uses the config's own `cache_dir` /// so a custom cache is honored, not just the default. pub fn is_model_provisioned(&self, model: &str) -> bool { - let dir = self.cache_dir.join(sanitize(model)); - crate::model::REQUIRED_FILES - .iter() - .all(|f| dir.join(f).is_file()) + crate::model::files_present(&self.cache_dir.join(sanitize(model))) } /// The full registry annotated against this config — the data the settings picker diff --git a/crates/b2-embed/src/model.rs b/crates/b2-embed/src/model.rs index a8c89cf..637c7dd 100644 --- a/crates/b2-embed/src/model.rs +++ b/crates/b2-embed/src/model.rs @@ -14,9 +14,16 @@ use tokenizers::{ }; /// The three files a BERT sentence model needs. Presence of all three in the flat -/// model dir *is* the "installed" check (fail-fast surface). +/// model dir *is* the "installed" check (fail-fast surface) — [`files_present`]. pub const REQUIRED_FILES: [&str; 3] = ["config.json", "tokenizer.json", "model.safetensors"]; +/// Whether every [`REQUIRED_FILES`] entry sits in `dir` — **the** "installed" +/// check, shared by [`LocalEmbedder::load`]'s fail-fast, the provision fast path, +/// and the settings picker's installed flag, so the three can never drift. +pub fn files_present(dir: &std::path::Path) -> bool { + REQUIRED_FILES.iter().all(|f| dir.join(f).is_file()) +} + /// BERT's positional limit; longer chunks are truncated so position embeddings are /// never indexed out of range. Capped again by the model's own config. const MAX_TOKENS: usize = 512; @@ -38,13 +45,11 @@ impl LocalEmbedder { /// never downloads. pub fn load(config: &EmbedConfig) -> Result { let dir = config.model_dir(); - for f in REQUIRED_FILES { - if !dir.join(f).is_file() { - return Err(EmbedError::NotProvisioned { - model: config.model.clone(), - dir: dir.display().to_string(), - }); - } + if !files_present(&dir) { + return Err(EmbedError::NotProvisioned { + model: config.model.clone(), + dir: dir.display().to_string(), + }); } let bert_config: Config = @@ -169,23 +174,26 @@ impl Embedder for LocalEmbedder { } fn embed(&self, text: &str) -> b2_core::Result> { - self.embed_inner(text) - .map_err(|e| b2_core::Error::Embed(e.to_string())) + self.embed_inner(text).map_err(embed_err) } fn embed_query(&self, text: &str) -> b2_core::Result> { // Asymmetric: queries carry the retrieval instruction, documents don't. let prefixed = format!("{}{}", self.query_prefix, text); - self.embed_inner(&prefixed) - .map_err(|e| b2_core::Error::Embed(e.to_string())) + self.embed_inner(&prefixed).map_err(embed_err) } fn embed_batch(&self, texts: &[&str]) -> b2_core::Result>> { - self.embed_batch_inner(texts) - .map_err(|e| b2_core::Error::Embed(e.to_string())) + self.embed_batch_inner(texts).map_err(embed_err) } } +/// A candle error crossing the [`Embedder`] seam, as the core's error type. A free +/// fn rather than a `From` impl — both types are foreign here (orphan rule). +fn embed_err(e: candle_core::Error) -> b2_core::Error { + b2_core::Error::Embed(e.to_string()) +} + fn l2_normalize(v: &[f32]) -> Vec { let norm = v.iter().map(|x| x * x).sum::().sqrt().max(1e-12); v.iter().map(|x| x / norm).collect() diff --git a/crates/b2-embed/src/provision.rs b/crates/b2-embed/src/provision.rs index a88c949..ac97f1a 100644 --- a/crates/b2-embed/src/provision.rs +++ b/crates/b2-embed/src/provision.rs @@ -4,7 +4,7 @@ //! loadable model is a no-op. use crate::config::{EmbedConfig, Source}; -use crate::model::{LocalEmbedder, REQUIRED_FILES}; +use crate::model::{files_present, LocalEmbedder, REQUIRED_FILES}; use crate::{EmbedError, Result}; use b2_core::embed::Embedder; use hf_hub::api::sync::ApiBuilder; @@ -29,7 +29,7 @@ pub fn provision(config: &EmbedConfig, mut log: impl FnMut(&str)) -> Result Date: Mon, 3 Aug 2026 04:15:43 +0000 Subject: [PATCH 2/4] cli: dispatch becomes a thin router over per-command fns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure code motion plus adapter dedup, byte-identical output (the 48-test output contract passes unchanged). Each subcommand's body moves verbatim out of the 620-line dispatch match into its own cmd_* fn; the reindex arm's two println! walls split into print_reindex_plan / print_reindex_report. The 19 hand-rolled --json sites share one print_json (serde joins the manifest for the trait bound only); mv/rm stop serializing reports they throw away in human mode; the presentation rules get names (display_name, arrow, decorate, is_dir_arg, print_rewrite_tally, print_dangled); the 10-arm debug-detail match — every arm already equal to err.to_string() through #[error(transparent)] — collapses to that one call; open_vault drops the (Vault, bool) tuple nine of ten callers discarded (search asks use_fake_embedder() directly, same value by construction); vault_or_cwd returns &Path instead of cloning. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WE6yzYZhgzwrwYpzaYzMs2 --- Cargo.lock | 1 + crates/b2-cli/Cargo.toml | 3 + crates/b2-cli/src/main.rs | 1321 +++++++++++++++++++------------------ 3 files changed, 698 insertions(+), 627 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db0838e..b3dda3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,6 +337,7 @@ dependencies = [ "clap", "ctrlc", "nix", + "serde", "serde_json", "tempfile", "thiserror 2.0.18", diff --git a/crates/b2-cli/Cargo.toml b/crates/b2-cli/Cargo.toml index 22082a4..493710a 100644 --- a/crates/b2-cli/Cargo.toml +++ b/crates/b2-cli/Cargo.toml @@ -13,6 +13,9 @@ path = "src/main.rs" b2-core = { path = "../b2-core" } b2-embed = { path = "../b2-embed" } clap = { version = "4.6.1", features = ["derive", "env"] } +# The `Serialize` bound on `print_json` — the one shared `--json` printer. Trait only; +# the view types it prints derive their impls in b2-core, so no `derive` feature here. +serde = "1.0.228" serde_json = "1.0.150" thiserror = "2.0.18" # Foreground `reindex` Ctrl-C → cooperative cancel (GH #16): a SIGINT handler flips a diff --git a/crates/b2-cli/src/main.rs b/crates/b2-cli/src/main.rs index 1ea6e6f..70aa09d 100644 --- a/crates/b2-cli/src/main.rs +++ b/crates/b2-cli/src/main.rs @@ -174,8 +174,8 @@ impl Cli { /// The vault root for **read-only** commands (`search`, `neighbors`, `explain`, /// `similar`): the `-C`/`$B2_VAULT_PATH` value if given, else the current directory. /// A pure read can't pollute anything, so the cwd convenience is safe here. - fn vault_or_cwd(&self) -> PathBuf { - self.vault.clone().unwrap_or_else(|| PathBuf::from(".")) + fn vault_or_cwd(&self) -> &Path { + self.vault.as_deref().unwrap_or_else(|| Path::new(".")) } /// The vault root for commands that **write** to the vault (`reindex`, `add`, `mv`, @@ -249,672 +249,750 @@ fn init_logging() { .with_env_filter(filter); // A CLI run is short-lived and single-threaded at the log site, so a plain // `Mutex` writer suffices — no async appender needed. - match log_file.map(|p| { - std::fs::OpenOptions::new() + match log_file { + Some(p) => match std::fs::OpenOptions::new() .create(true) .append(true) .open(std::path::Path::new(&p)) - .map_err(|e| (p, e)) - }) { - Some(Ok(file)) => builder.with_writer(std::sync::Mutex::new(file)).init(), - Some(Err((path, e))) => { - eprintln!( - "warning: cannot open B2_LOG_FILE '{}' ({e}); logging to stderr", - path.to_string_lossy() - ); - builder.with_writer(std::io::stderr).init(); - } + { + Ok(file) => builder.with_writer(std::sync::Mutex::new(file)).init(), + Err(e) => { + eprintln!( + "warning: cannot open B2_LOG_FILE '{}' ({e}); logging to stderr", + p.to_string_lossy() + ); + builder.with_writer(std::io::stderr).init(); + } + }, None => builder.with_writer(std::io::stderr).init(), } } +/// The thin router: each subcommand's whole behavior lives in its `cmd_*` fn below; +/// this match only destructures the parsed args and forwards them. fn dispatch(cli: &Cli) -> Result<(), CliError> { match &cli.command { - Command::Init => { - // Global, per-machine setup — no vault involved. - let config = EmbedConfig::load()?; - let report = provision(&config, |line| eprintln!("{line}"))?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&report)?); - } else if report.already_present { - println!("Model '{}' is already installed.", report.model); - } else { - println!( - "Installed '{}' ({} dims). Run `b2 reindex` to embed your vault.", - report.model, report.dim - ); - } - } + Command::Init => cmd_init(cli.json), Command::Reindex { vault, force, dry_run, cancel, - } => { - // Reindex writes an index → require an explicit vault (positional wins), - // never a silent cwd fallback. See `Cli::require_vault`. - let root = cli.require_vault(vault.as_deref())?; - if *cancel { - // Signals another process and returns; it never opens the vault (no - // model load, no index read) — the run being cancelled owns all of that. - return cancel_reindex(root, cli.json); - } - if *dry_run { - // A dry-run neither embeds nor stamps → no model needed (open with - // the fake, like `neighbors`); it's a pure read, so there's no slow - // embed phase to show progress for. - let (vault, _semantic) = open_vault(root, false)?; - let plan = vault.plan_reindex(*force)?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&plan)?); - } else { - println!( - "Dry run: would index {} note(s) — {} to embed, {} to stamp. No changes made.", - plan.would_index, plan.would_embed, plan.would_stamp - ); - // The GH #81 previews: which notes have no identity yet, which - // stamps would *change* an identity, and which files contest one. - if !plan.stamp_paths.is_empty() { - println!("Notes without a b2id (a real run stamps these):"); - for p in &plan.stamp_paths { - println!(" - {p}"); - } - } - if !plan.would_restamp.is_empty() { - println!( - "Would restamp identity (the b2id line was removed or blanked; links to the old id will dangle):" - ); - for r in &plan.would_restamp { - println!(" - {} (was {})", r.path, r.old_b2id); - } - } - for c in &plan.collisions { - println!( - "Duplicate b2id {}: a real run keeps {} and leaves {} un-indexed until resolved.", - c.b2id, - c.kept_path, - c.shadowed_paths.join(", ") - ); - } - } - return Ok(()); - } - // Single-in-flight: take an advisory lock *before* the (slow) model load so - // a second `b2 reindex` — e.g. a foreground run racing one you backgrounded - // with `b2 reindex &` — refuses cleanly instead of two processes writing the - // same index. Advisory, not a PID file: the OS frees it the instant the holder - // exits (crash, kill, or Ctrl-C included), so nothing stale is ever left behind. - // `lock` is held until this arm ends; dropping it releases the lock. - let lock = open_reindex_lock(root)?; - match lock.try_lock() { - Ok(()) => {} - Err(std::fs::TryLockError::WouldBlock) => return Err(CliError::ReindexRunning), - Err(std::fs::TryLockError::Error(e)) => return Err(CliError::Io(e)), - } - // Now that the lock is ours, stamp who holds it: the address `b2 reindex - // --cancel` signals, and what `b2 status` prints so a manual `kill` stays - // available (GH #55). Best-effort — a failed write costs the cancel - // affordance, not the reindex. - let _ = record_reindex_pid(&lock); - // Reindex embeds every changed chunk → it needs the real model. - let (vault, _semantic) = open_vault(root, true)?; - // Wire Ctrl-C to the cooperative-cancel flag now that the model is loaded and - // real embedding is next. (During the model load the default SIGINT still - // applies — nothing is written yet, so a hard stop there is safe.) Best-effort: - // if the handler can't be installed, Ctrl-C keeps its default (terminate), which - // still leaves a consistent index since edges + FTS land before any vectors. - let _ = ctrlc::set_handler(|| CANCEL.store(true, Ordering::SeqCst)); - // Embedding a large vault on CPU is slow; show a live progress line so it - // never looks frozen. Only on an interactive stderr (never in --json, and - // never when piped/captured) so machine output and tests stay clean. - let report = if cli.json || !std::io::stderr().is_terminal() { - vault.reindex_with_progress(*force, &mut |_| cancel_flow())? - } else { - // Name the vault being indexed up front, then a live line that counts - // the notes actually (re)embedded — not every note, most of which an - // incremental run reuses untouched — with the current file + its chunks. - let shown = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); - eprintln!("Indexing {}", shown.display()); - let mut progressed = false; - let mut on_progress = |p: b2_core::ingest::ReindexProgress| { - progressed = true; - // \x1b[K clears any tail of a previous, longer line (paths vary in - // length); safe here because this branch only runs on a real terminal. - eprint!( - "\r embedding {}/{} · {} ({} chunk{})\x1b[K", - p.notes_embedded, - p.notes_to_embed, - p.note_path, - p.note_chunks, - if p.note_chunks == 1 { "" } else { "s" }, - ); - let _ = std::io::stderr().flush(); - // Stop after this batch if Ctrl-C was pressed, - // else carry on. The batch is already written above, so a cancel here - // never tears a write. - cancel_flow() - }; - let report = vault.reindex_with_progress(*force, &mut on_progress)?; - if progressed { - eprintln!(); // close the progress line - } - report - }; - if cli.json { - println!("{}", serde_json::to_string_pretty(&report)?); - } else { - println!( - "Indexed {} notes ({} embedded, {} stamped{}) and {} resources{}", - report.indexed, - report.embedded, - report.stamped, - if report.notes_pruned > 0 { - format!(", {} pruned", report.notes_pruned) - } else { - String::new() - }, - report.resources_indexed, - if report.resources_pruned > 0 { - format!(" ({} pruned)", report.resources_pruned) - } else { - String::new() - } - ); - // One unreadable file no longer aborts the reindex — it is skipped and - // named here (to stderr, so it never pollutes the machine-readable stdout - // line above) with a short, file-level reason. - if !report.skipped.is_empty() { - eprintln!("Skipped {} unreadable file(s):", report.skipped.len()); - for s in &report.skipped { - eprintln!(" - {} ({})", s.path, s.reason); - } - } - // The GH #81 anomaly notices (stderr, like `skipped`): surfaced every - // run until resolved, never auto-fixed — the human decides which file - // keeps a contested identity. - for c in &report.collisions { - let why = match c.precedence { - b2_core::vault::CollisionPrecedence::Incumbent => { - "it already held the identity" - } - b2_core::vault::CollisionPrecedence::TieBreak => { - "first in path order — b2 could not tell which file is the original" - } - }; - eprintln!( - "Duplicate b2id {}: kept {} ({}); not indexed: {}.", - c.b2id, - c.kept_path, - why, - c.shadowed_paths.join(", ") - ); - eprintln!( - " To resolve: delete the copy, or remove its `b2id:` line to give it a fresh identity." - ); - } - if !report.restamped.is_empty() { - eprintln!( - "Restamped identity on {} note(s) — the b2id line was removed or blanked outside b2, so links to the old identity now dangle:", - report.restamped.len() - ); - for r in &report.restamped { - eprintln!(" - {} (was {}, now {})", r.path, r.old_b2id, r.new_b2id); - } - } - // The counts above already report the partial work truthfully; add the - // one line that tells the user it was interrupted and is safe to resume. - if report.cancelled { - eprintln!( - "Cancelled — the index is consistent but only partly embedded. Re-run `b2 reindex` to finish the rest." - ); - } - } - } - Command::Status => { - // Read-only coverage report: how much of the vault is embedded (semantic - // ranking live vs. keyword-only) and whether a background reindex is in - // flight — the companion to backgrounding a slow reindex with `b2 reindex &`. - // A pure model-free DB read (#26): open with the fake. - let root = cli.vault_or_cwd(); - let (vault, _semantic) = open_vault(&root, false)?; - let status = vault.embed_status()?; - let holder = reindex_holder(&root); - if cli.json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "embedded": status.embedded, - "total": status.total, - "reindex_running": holder.is_some(), - // The running process's id — `null` when nothing is running (and - // on the sliver of a moment before a fresh holder stamps it). - "reindex_pid": holder.as_ref().and_then(|h| h.pid), - }))? - ); - } else { - if status.total == 0 { - println!("No notes indexed yet. Run `b2 reindex` to build the index."); - } else if status.embedded == 0 { - println!( - "Embedded 0/{} notes — keyword-only. Run `b2 reindex` for semantic ranking.", - status.total - ); - } else if status.embedded < status.total { - println!( - "Embedded {}/{} notes — semantic ranking partial ({} still keyword-only).", - status.embedded, - status.total, - status.total - status.embedded - ); - } else { - println!( - "Embedded {}/{} notes — semantic ranking fully live.", - status.embedded, status.total - ); - } - // Name the process, not just the fact: `--cancel` is the supported stop, - // and the pid keeps a plain `kill -INT` as the documented fallback. - match holder.as_ref().map(|h| h.pid) { - Some(Some(pid)) => println!( - "A reindex is currently running (pid {pid}). Stop it with `b2 reindex --cancel` (or `kill -INT {pid}`)." - ), - Some(None) => println!( - "A reindex is currently running. Stop it with `b2 reindex --cancel`." - ), - None => {} - } - } - } + } => cmd_reindex(cli, vault.as_deref(), *force, *dry_run, *cancel), + Command::Status => cmd_status(cli), Command::Add { path, title, content, - } => { - // Add writes a new note (and embeds its body) → require an explicit vault - // (no silent cwd), and it needs the real model like `reindex`/`mv`/`link`. - let (vault, _semantic) = open_vault(cli.require_vault(None)?, true)?; - let report = vault.add_note(path, title.as_deref(), content.as_deref())?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&report)?); - } else { - println!("Created {} (b2id {}).", report.path, report.b2id); - } + } => cmd_add(cli, path, title.as_deref(), content.as_deref()), + Command::Write { note } => cmd_write(cli, note), + Command::Neighbors { note } => cmd_neighbors(cli, note), + Command::Explain { note } => cmd_explain(cli, note), + Command::Mv { from, to } => cmd_mv(cli, from, to), + Command::Rm { target, recursive } => cmd_rm(cli, target, *recursive), + Command::Search { query, limit } => cmd_search(cli, query, *limit), + Command::Similar { note, limit } => cmd_similar(cli, note, *limit), + Command::Link { + src, + dst, + edge_type, + explanation, + } => cmd_link(cli, src, dst, edge_type, explanation.as_deref()), + } +} + +/// Print `value` as pretty JSON on stdout — the one `--json` output path, shared by +/// every subcommand. +fn print_json(value: &T) -> Result<(), CliError> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +fn cmd_init(json: bool) -> Result<(), CliError> { + // Global, per-machine setup — no vault involved. + let config = EmbedConfig::load()?; + let report = provision(&config, |line| eprintln!("{line}"))?; + if json { + print_json(&report)?; + } else if report.already_present { + println!("Model '{}' is already installed.", report.model); + } else { + println!( + "Installed '{}' ({} dims). Run `b2 reindex` to embed your vault.", + report.model, report.dim + ); + } + Ok(()) +} + +fn cmd_reindex( + cli: &Cli, + vault: Option<&Path>, + force: bool, + dry_run: bool, + cancel: bool, +) -> Result<(), CliError> { + // Reindex writes an index → require an explicit vault (positional wins), + // never a silent cwd fallback. See `Cli::require_vault`. + let root = cli.require_vault(vault)?; + if cancel { + // Signals another process and returns; it never opens the vault (no + // model load, no index read) — the run being cancelled owns all of that. + return cancel_reindex(root, cli.json); + } + if dry_run { + // A dry-run neither embeds nor stamps → no model needed (open with + // the fake, like `neighbors`); it's a pure read, so there's no slow + // embed phase to show progress for. + let vault = open_vault(root, false)?; + let plan = vault.plan_reindex(force)?; + if cli.json { + print_json(&plan)?; + } else { + print_reindex_plan(&plan); + } + return Ok(()); + } + // Single-in-flight: take an advisory lock *before* the (slow) model load so + // a second `b2 reindex` — e.g. a foreground run racing one you backgrounded + // with `b2 reindex &` — refuses cleanly instead of two processes writing the + // same index. Advisory, not a PID file: the OS frees it the instant the holder + // exits (crash, kill, or Ctrl-C included), so nothing stale is ever left behind. + // `lock` is held until this command fn ends; dropping it releases the lock. + let lock = open_reindex_lock(root)?; + match lock.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => return Err(CliError::ReindexRunning), + Err(std::fs::TryLockError::Error(e)) => return Err(CliError::Io(e)), + } + // Now that the lock is ours, stamp who holds it: the address `b2 reindex + // --cancel` signals, and what `b2 status` prints so a manual `kill` stays + // available (GH #55). Best-effort — a failed write costs the cancel + // affordance, not the reindex. + let _ = record_reindex_pid(&lock); + // Reindex embeds every changed chunk → it needs the real model. + let vault = open_vault(root, true)?; + // Wire Ctrl-C to the cooperative-cancel flag now that the model is loaded and + // real embedding is next. (During the model load the default SIGINT still + // applies — nothing is written yet, so a hard stop there is safe.) Best-effort: + // if the handler can't be installed, Ctrl-C keeps its default (terminate), which + // still leaves a consistent index since edges + FTS land before any vectors. + let _ = ctrlc::set_handler(|| CANCEL.store(true, Ordering::SeqCst)); + // Embedding a large vault on CPU is slow; show a live progress line so it + // never looks frozen. Only on an interactive stderr (never in --json, and + // never when piped/captured) so machine output and tests stay clean. + let report = if cli.json || !std::io::stderr().is_terminal() { + vault.reindex_with_progress(force, &mut |_| cancel_flow())? + } else { + // Name the vault being indexed up front, then a live line that counts + // the notes actually (re)embedded — not every note, most of which an + // incremental run reuses untouched — with the current file + its chunks. + let shown = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + eprintln!("Indexing {}", shown.display()); + let mut progressed = false; + let mut on_progress = |p: b2_core::ingest::ReindexProgress| { + progressed = true; + // \x1b[K clears any tail of a previous, longer line (paths vary in + // length); safe here because this branch only runs on a real terminal. + eprint!( + "\r embedding {}/{} · {} ({} chunk{})\x1b[K", + p.notes_embedded, + p.notes_to_embed, + p.note_path, + p.note_chunks, + if p.note_chunks == 1 { "" } else { "s" }, + ); + let _ = std::io::stderr().flush(); + // Stop after this batch if Ctrl-C was pressed, + // else carry on. The batch is already written above, so a cancel here + // never tears a write. + cancel_flow() + }; + let report = vault.reindex_with_progress(force, &mut on_progress)?; + if progressed { + eprintln!(); // close the progress line + } + report + }; + if cli.json { + print_json(&report)?; + } else { + print_reindex_report(&report); + } + Ok(()) +} + +/// The human-readable `reindex --dry-run` preview (the `--json` sibling prints the +/// plan itself). +fn print_reindex_plan(plan: &b2_core::vault::ReindexPlan) { + println!( + "Dry run: would index {} note(s) — {} to embed, {} to stamp. No changes made.", + plan.would_index, plan.would_embed, plan.would_stamp + ); + // The GH #81 previews: which notes have no identity yet, which + // stamps would *change* an identity, and which files contest one. + if !plan.stamp_paths.is_empty() { + println!("Notes without a b2id (a real run stamps these):"); + for p in &plan.stamp_paths { + println!(" - {p}"); + } + } + if !plan.would_restamp.is_empty() { + println!( + "Would restamp identity (the b2id line was removed or blanked; links to the old id will dangle):" + ); + for r in &plan.would_restamp { + println!(" - {} (was {})", r.path, r.old_b2id); + } + } + for c in &plan.collisions { + println!( + "Duplicate b2id {}: a real run keeps {} and leaves {} un-indexed until resolved.", + c.b2id, + c.kept_path, + c.shadowed_paths.join(", ") + ); + } +} + +/// The human-readable reindex summary: the one stdout line, then the stderr +/// notices — skipped files, the GH #81 anomalies, and the cancelled line. +fn print_reindex_report(report: &b2_core::vault::ReindexReport) { + println!( + "Indexed {} notes ({} embedded, {} stamped{}) and {} resources{}", + report.indexed, + report.embedded, + report.stamped, + if report.notes_pruned > 0 { + format!(", {} pruned", report.notes_pruned) + } else { + String::new() + }, + report.resources_indexed, + if report.resources_pruned > 0 { + format!(" ({} pruned)", report.resources_pruned) + } else { + String::new() } - Command::Write { note } => { - // A body splice + re-projection: **model-free** (like the desktop's save and - // `rm`), still an explicit vault like every write. Refuse an interactive - // terminal up front so the command never silently hangs waiting for - // hand-typed input — the new body is always *piped* (an agent, `cat file |`, …). - let stdin = std::io::stdin(); - if stdin.is_terminal() { - return Err(CliError::StdinRequired); + ); + // One unreadable file no longer aborts the reindex — it is skipped and + // named here (to stderr, so it never pollutes the machine-readable stdout + // line above) with a short, file-level reason. + if !report.skipped.is_empty() { + eprintln!("Skipped {} unreadable file(s):", report.skipped.len()); + for s in &report.skipped { + eprintln!(" - {} ({})", s.path, s.reason); + } + } + // The GH #81 anomaly notices (stderr, like `skipped`): surfaced every + // run until resolved, never auto-fixed — the human decides which file + // keeps a contested identity. + for c in &report.collisions { + let why = match c.precedence { + b2_core::vault::CollisionPrecedence::Incumbent => "it already held the identity", + b2_core::vault::CollisionPrecedence::TieBreak => { + "first in path order — b2 could not tell which file is the original" } - let (vault, _semantic) = open_vault(cli.require_vault(None)?, false)?; - let mut body = String::new(); - stdin.lock().read_to_string(&mut body)?; - // Stateless one-shot: read the current on-disk revision and chain the write on - // it. A CLI holds no long-lived buffer, so there's no external-edit window to - // guard here — the content-hash guard exists for the desktop's in-memory - // buffer; for a one-shot, the contract is simply "the body becomes exactly - // this" (`Vault::write` still keeps the frontmatter bytes untouched). - let current = vault.read(note)?; - let report = vault.write(note, &body, ¤t.revision)?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&report)?); - } else { - println!("Wrote {} ({} bytes).", report.path, body.len()); + }; + eprintln!( + "Duplicate b2id {}: kept {} ({}); not indexed: {}.", + c.b2id, + c.kept_path, + why, + c.shadowed_paths.join(", ") + ); + eprintln!( + " To resolve: delete the copy, or remove its `b2id:` line to give it a fresh identity." + ); + } + if !report.restamped.is_empty() { + eprintln!( + "Restamped identity on {} note(s) — the b2id line was removed or blanked outside b2, so links to the old identity now dangle:", + report.restamped.len() + ); + for r in &report.restamped { + eprintln!(" - {} (was {}, now {})", r.path, r.old_b2id, r.new_b2id); + } + } + // The counts above already report the partial work truthfully; add the + // one line that tells the user it was interrupted and is safe to resume. + if report.cancelled { + eprintln!( + "Cancelled — the index is consistent but only partly embedded. Re-run `b2 reindex` to finish the rest." + ); + } +} + +fn cmd_status(cli: &Cli) -> Result<(), CliError> { + // Read-only coverage report: how much of the vault is embedded (semantic + // ranking live vs. keyword-only) and whether a background reindex is in + // flight — the companion to backgrounding a slow reindex with `b2 reindex &`. + // A pure model-free DB read (#26): open with the fake. + let root = cli.vault_or_cwd(); + let vault = open_vault(root, false)?; + let status = vault.embed_status()?; + let holder = reindex_holder(root); + if cli.json { + print_json(&serde_json::json!({ + "embedded": status.embedded, + "total": status.total, + "reindex_running": holder.is_some(), + // The running process's id — `null` when nothing is running (and + // on the sliver of a moment before a fresh holder stamps it). + "reindex_pid": holder.as_ref().and_then(|h| h.pid), + }))?; + } else { + if status.total == 0 { + println!("No notes indexed yet. Run `b2 reindex` to build the index."); + } else if status.embedded == 0 { + println!( + "Embedded 0/{} notes — keyword-only. Run `b2 reindex` for semantic ranking.", + status.total + ); + } else if status.embedded < status.total { + println!( + "Embedded {}/{} notes — semantic ranking partial ({} still keyword-only).", + status.embedded, + status.total, + status.total - status.embedded + ); + } else { + println!( + "Embedded {}/{} notes — semantic ranking fully live.", + status.embedded, status.total + ); + } + // Name the process, not just the fact: `--cancel` is the supported stop, + // and the pid keeps a plain `kill -INT` as the documented fallback. + match holder.as_ref().map(|h| h.pid) { + Some(Some(pid)) => println!( + "A reindex is currently running (pid {pid}). Stop it with `b2 reindex --cancel` (or `kill -INT {pid}`)." + ), + Some(None) => { + println!("A reindex is currently running. Stop it with `b2 reindex --cancel`.") } + None => {} + } + } + Ok(()) +} + +fn cmd_add( + cli: &Cli, + path: &str, + title: Option<&str>, + content: Option<&str>, +) -> Result<(), CliError> { + // Add writes a new note (and embeds its body) → require an explicit vault + // (no silent cwd), and it needs the real model like `reindex`/`mv`/`link`. + let vault = open_vault(cli.require_vault(None)?, true)?; + let report = vault.add_note(path, title, content)?; + if cli.json { + print_json(&report)?; + } else { + println!("Created {} (b2id {}).", report.path, report.b2id); + } + Ok(()) +} + +fn cmd_write(cli: &Cli, note: &str) -> Result<(), CliError> { + // A body splice + re-projection: **model-free** (like the desktop's save and + // `rm`), still an explicit vault like every write. Refuse an interactive + // terminal up front so the command never silently hangs waiting for + // hand-typed input — the new body is always *piped* (an agent, `cat file |`, …). + let stdin = std::io::stdin(); + if stdin.is_terminal() { + return Err(CliError::StdinRequired); + } + let vault = open_vault(cli.require_vault(None)?, false)?; + let mut body = String::new(); + stdin.lock().read_to_string(&mut body)?; + // Stateless one-shot: read the current on-disk revision and chain the write on + // it. A CLI holds no long-lived buffer, so there's no external-edit window to + // guard here — the content-hash guard exists for the desktop's in-memory + // buffer; for a one-shot, the contract is simply "the body becomes exactly + // this" (`Vault::write` still keeps the frontmatter bytes untouched). + let current = vault.read(note)?; + let report = vault.write(note, &body, ¤t.revision)?; + if cli.json { + print_json(&report)?; + } else { + println!("Wrote {} ({} bytes).", report.path, body.len()); + } + Ok(()) +} + +fn cmd_neighbors(cli: &Cli, note: &str) -> Result<(), CliError> { + // Neighbors is a pure graph query — it never embeds, so don't require + // the model (no needless `b2 init` just to explore the graph). + let vault = open_vault(cli.vault_or_cwd(), false)?; + let neighbors = vault.neighbors(note)?; + // Dangling outbound links (a `[[folder]]` or a typo) that resolve to no + // note or resource — surfaced, not dropped (GH #12). `--json` keeps its + // resolved-neighbors array contract; the full structured picture, + // including these, is `b2 explain --json`. + let unresolved = vault.unresolved_links(note)?; + if cli.json { + print_json(&neighbors)?; + } else if neighbors.is_empty() && unresolved.is_empty() { + println!("No neighbors."); + } else { + for n in &neighbors { + let arrow = arrow(&n.direction); + let name = display_name(n.title.as_deref(), &n.path); + let explanation = n + .explanation + .as_deref() + .map(|e| format!(" — {e}")) + .unwrap_or_default(); + println!("{arrow} {} {name} ({}){explanation}", n.label, n.path); } - Command::Neighbors { note } => { - // Neighbors is a pure graph query — it never embeds, so don't require - // the model (no needless `b2 init` just to explore the graph). - let (vault, _semantic) = open_vault(&cli.vault_or_cwd(), false)?; - let neighbors = vault.neighbors(note)?; - // Dangling outbound links (a `[[folder]]` or a typo) that resolve to no - // note or resource — surfaced, not dropped (GH #12). `--json` keeps its - // resolved-neighbors array contract; the full structured picture, - // including these, is `b2 explain --json`. - let unresolved = vault.unresolved_links(note)?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&neighbors)?); - } else if neighbors.is_empty() && unresolved.is_empty() { - println!("No neighbors."); + for u in &unresolved { + println!( + "⚠ {} [[{}]] — unresolved (no matching note or file)", + u.relation, u.target + ); + } + } + Ok(()) +} + +fn cmd_explain(cli: &Cli, note: &str) -> Result<(), CliError> { + // Explain is a pure graph read (edges + their explanations), no embed — + // like `neighbors`, it opens with the fake and needs no `b2 init`. + let vault = open_vault(cli.vault_or_cwd(), false)?; + // Kind dispatch by the argument's own shape (core's one rule, §9b #8): + // a resource arg gets the fallback card's view — metadata + backlinks. + if doc_kind(note) == DocKind::Resource { + let view = vault.explain_resource(note)?; + if cli.json { + print_json(&view)?; + } else { + println!("{} ({}, {} bytes)", view.path, view.class, view.size); + if view.backlinks.is_empty() { + println!("No backlinks yet."); } else { - for n in &neighbors { - let arrow = if n.direction == "outbound" { - "→" - } else { - "←" - }; - let name = n.title.as_deref().unwrap_or(&n.path); - let explanation = n - .explanation - .as_deref() - .map(|e| format!(" — {e}")) - .unwrap_or_default(); - println!("{arrow} {} {name} ({}){explanation}", n.label, n.path); - } - for u in &unresolved { - println!( - "⚠ {} [[{}]] — unresolved (no matching note or file)", - u.relation, u.target - ); + println!("Backlinks:"); + for b in &view.backlinks { + let name = display_name(b.title.as_deref(), &b.path); + let mut line = format!(" ← {name} ({}) {}", b.path, b.r#type); + decorate(&mut line, b.embed, b.caption.as_deref()); + println!("{line}"); } } } - Command::Explain { note } => { - // Explain is a pure graph read (edges + their explanations), no embed — - // like `neighbors`, it opens with the fake and needs no `b2 init`. - let (vault, _semantic) = open_vault(&cli.vault_or_cwd(), false)?; - // Kind dispatch by the argument's own shape (core's one rule, §9b #8): - // a resource arg gets the fallback card's view — metadata + backlinks. - if doc_kind(note) == DocKind::Resource { - let view = vault.explain_resource(note)?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&view)?); - } else { - println!("{} ({}, {} bytes)", view.path, view.class, view.size); - if view.backlinks.is_empty() { - println!("No backlinks yet."); - } else { - println!("Backlinks:"); - for b in &view.backlinks { - let name = b.title.as_deref().unwrap_or(&b.path); - let mut line = format!(" ← {name} ({}) {}", b.path, b.r#type); - if b.embed { - line.push_str(" (embed)"); - } - if let Some(c) = &b.caption { - line.push_str(&format!(" — \"{c}\"")); - } - println!("{line}"); - } - } + return Ok(()); + } + let view = vault.explain(note)?; + if cli.json { + print_json(&view)?; + } else { + let name = display_name(view.title.as_deref(), &view.path); + println!("{name} ({}) [b2id {}]", view.path, view.b2id); + if view.connections.is_empty() && view.resources.is_empty() && view.unresolved.is_empty() { + // Zero connections at all — nothing links to it and it links to + // nothing (an orphan; the kernel only surfaces, never archives). + println!("No connections yet."); + } else if !view.connections.is_empty() { + println!("Connections:"); + for c in &view.connections { + let arrow = arrow(&c.direction); + let target = display_name(c.title.as_deref(), &c.path); + println!( + " {arrow} {} {target} ({}) [{}]", + c.label, c.path, c.origin + ); + if let Some(why) = &c.explanation { + println!(" why: {why}"); } - return Ok(()); } - let view = vault.explain(note)?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&view)?); - } else { - let name = view.title.as_deref().unwrap_or(&view.path); - println!("{name} ({}) [b2id {}]", view.path, view.b2id); - if view.connections.is_empty() - && view.resources.is_empty() - && view.unresolved.is_empty() - { - // Zero connections at all — nothing links to it and it links to - // nothing (an orphan; the kernel only surfaces, never archives). - println!("No connections yet."); - } else if !view.connections.is_empty() { - println!("Connections:"); - for c in &view.connections { - let arrow = if c.direction == "outbound" { - "→" - } else { - "←" - }; - let target = c.title.as_deref().unwrap_or(&c.path); - println!( - " {arrow} {} {target} ({}) [{}]", - c.label, c.path, c.origin - ); - if let Some(why) = &c.explanation { - println!(" why: {why}"); - } - } - // If nothing points *at* the note, it's an orphan — surfaced, not - // acted on (invariants.md; files are only touched when asked). - if !view.connections.iter().any(|c| c.direction == "inbound") { - println!("No inbound links — this note is an orphan."); - } - } - // Outbound links at resources (images, PDFs, …) — the third target - // kind an edge can have, shown from the note's side (GH #22). - if !view.resources.is_empty() { - println!("Resource links:"); - for r in &view.resources { - let mut line = format!( - " → {} {} ({}) [{}]", - r.relation, r.path, r.class, r.origin - ); - if r.embed { - line.push_str(" (embed)"); - } - if let Some(c) = &r.caption { - line.push_str(&format!(" — \"{c}\"")); - } - println!("{line}"); - if let Some(why) = &r.explanation { - println!(" why: {why}"); - } - } - } - // Dangling outbound links (a `[[folder]]` or a typo): a note is one - // `.md` file, so these resolve to nothing — shown as broken rather - // than silently dropped (GH #12). - if !view.unresolved.is_empty() { - println!("Unresolved links:"); - for u in &view.unresolved { - println!( - " ⚠ {} [[{}]] (no matching note or file) [{}]", - u.relation, u.target, u.origin - ); - if let Some(why) = &u.explanation { - println!(" why: {why}"); - } - } - } + // If nothing points *at* the note, it's an orphan — surfaced, not + // acted on (invariants.md; files are only touched when asked). + if !view.connections.iter().any(|c| c.direction == "inbound") { + println!("No inbound links — this note is an orphan."); } } - Command::Mv { from, to } => { - // A move rewrites files (and re-embeds them on re-projection) → require an - // explicit vault (no silent cwd), and it needs the real model the index was - // built with, like `reindex`/`add`/`link`. - let root = cli.require_vault(None)?; - let (vault, _semantic) = open_vault(root, true)?; - // Kind dispatch (§9b #8): an existing directory moves as a folder - // (every file under it, one rename); otherwise the two file arms - // differ only in the report type (a resource has no b2id to carry). - // The human "Moved" line is preformatted per arm, the rewrite tally - // is shared. - let (moved_line, links_rewritten, rewrote_files, json) = - if root.join(from.trim_end_matches('/')).is_dir() { - let report = vault.move_dir(from, to)?; - let json = serde_json::to_string_pretty(&report)?; - ( - format!( - "Moved {}/ → {}/ ({} note(s), {} file(s))", - report.from, report.to, report.moved_notes, report.moved_resources - ), - report.links_rewritten, - report.rewrote.len(), - json, - ) - } else if doc_kind(from) == DocKind::Resource { - let report = vault.move_resource(from, to)?; - let json = serde_json::to_string_pretty(&report)?; - ( - format!("Moved {} → {}", report.from, report.to), - report.links_rewritten, - report.rewrote.len(), - json, - ) - } else { - let report = vault.move_note(from, to)?; - let json = serde_json::to_string_pretty(&report)?; - ( - format!("Moved {} → {}", report.from, report.to), - report.links_rewritten, - report.rewrote.len(), - json, - ) - }; - if cli.json { - println!("{json}"); - } else { - println!("{moved_line}"); - if links_rewritten > 0 { - println!( - "Rewrote {links_rewritten} inbound link(s) across {rewrote_files} file(s)." - ); - } else { - println!("No inbound links to rewrite."); + // Outbound links at resources (images, PDFs, …) — the third target + // kind an edge can have, shown from the note's side (GH #22). + if !view.resources.is_empty() { + println!("Resource links:"); + for r in &view.resources { + let mut line = format!( + " → {} {} ({}) [{}]", + r.relation, r.path, r.class, r.origin + ); + decorate(&mut line, r.embed, r.caption.as_deref()); + println!("{line}"); + if let Some(why) = &r.explanation { + println!(" why: {why}"); } } } - Command::Rm { target, recursive } => { - // A delete removes files and index rows but never rewrites a body - // (inbound links dangle, they aren't repaired) → **model-free**, like - // the desktop's delete; still an explicit vault, like every write. - let root = cli.require_vault(None)?; - let (vault, _semantic) = open_vault(root, false)?; - // Kind dispatch (§9b #8), mirroring `mv`: an existing directory deletes - // as a folder — gated on --recursive, the CLI's stand-in for the - // desktop's confirm dialog — else the extension picks the file arm. - let (deleted_line, dangled, json) = if root.join(target.trim_end_matches('/')).is_dir() - { - if !*recursive { - return Err(CliError::RecursiveRequired(target.clone())); - } - let report = vault.delete_dir(target)?; - let json = serde_json::to_string_pretty(&report)?; - ( - format!( - "Deleted {}/ ({} note(s), {} file(s))", - report.dir, report.deleted_notes, report.deleted_resources - ), - report.dangled, - json, - ) - } else if doc_kind(target) == DocKind::Resource { - let report = vault.delete_resource(target)?; - let json = serde_json::to_string_pretty(&report)?; - (format!("Deleted {}", report.path), report.dangled, json) - } else { - let report = vault.delete_note(target)?; - let json = serde_json::to_string_pretty(&report)?; - (format!("Deleted {}", report.path), report.dangled, json) - }; - if cli.json { - println!("{json}"); - } else { - println!("{deleted_line}"); - if dangled.is_empty() { - println!("No inbound links affected."); - } else { - println!( - "Links in {} file(s) now unresolved: {}", - dangled.len(), - dangled.join(", ") - ); + // Dangling outbound links (a `[[folder]]` or a typo): a note is one + // `.md` file, so these resolve to nothing — shown as broken rather + // than silently dropped (GH #12). + if !view.unresolved.is_empty() { + println!("Unresolved links:"); + for u in &view.unresolved { + println!( + " ⚠ {} [[{}]] (no matching note or file) [{}]", + u.relation, u.target, u.origin + ); + if let Some(why) = &u.explanation { + println!(" why: {why}"); } } } - Command::Search { query, limit } => { - // Search embeds the query for the vector half → it needs the real model. - let (vault, semantic) = open_vault(&cli.vault_or_cwd(), true)?; - let results = vault.search(query, *limit)?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&results)?); - } else { - if results.is_empty() { - println!("No results."); - } else { - for r in &results { - let name = r.title.as_deref().unwrap_or(&r.path); - println!("{:.4} {name} ({})", r.score, r.path); - if !r.snippet.is_empty() { - println!(" {}", r.snippet); - } - } - } - // Honesty (never overstate): with the fake embedder the vector half - // isn't semantic. Under the real model it is, so no caveat. Kept on - // stderr so stdout stays pure results. - if !semantic { - eprintln!( - "note: keyword (BM25) ranking is live; semantic ranking is off (fake embedder)." - ); - } - } + } + Ok(()) +} + +fn cmd_mv(cli: &Cli, from: &str, to: &str) -> Result<(), CliError> { + // A move rewrites files (and re-embeds them on re-projection) → require an + // explicit vault (no silent cwd), and it needs the real model the index was + // built with, like `reindex`/`add`/`link`. + let root = cli.require_vault(None)?; + let vault = open_vault(root, true)?; + // Kind dispatch (§9b #8): an existing directory moves as a folder + // (every file under it, one rename); otherwise the two file arms + // differ only in the report type (a resource has no b2id to carry). + // The human "Moved" line differs per arm, the rewrite tally is shared. + if is_dir_arg(root, from) { + let report = vault.move_dir(from, to)?; + if cli.json { + print_json(&report)?; + } else { + println!( + "Moved {}/ → {}/ ({} note(s), {} file(s))", + report.from, report.to, report.moved_notes, report.moved_resources + ); + print_rewrite_tally(report.links_rewritten, report.rewrote.len()); } - Command::Similar { note, limit } => { - // Candidate generation reads the *stored* vectors (no query embedding), so - // like `neighbors` it needs no live model — a prior `reindex` supplies them. - // Open with the fake; it's a pure, instant local read. - let (vault, _semantic) = open_vault(&cli.vault_or_cwd(), false)?; - let results = vault.similar(note, *limit)?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&results)?); - } else if results.is_empty() { - println!( - "No similar notes. (If you haven't yet, run `b2 init` then `b2 reindex` so similarity is semantic.)" - ); - } else { - for r in results.iter() { - let name = r.title.as_deref().unwrap_or(&r.path); - println!("{:.4} {name} ({})", r.score, r.path); - if !r.evidence.is_empty() { - println!(" {}", r.evidence); - } + } else if doc_kind(from) == DocKind::Resource { + let report = vault.move_resource(from, to)?; + if cli.json { + print_json(&report)?; + } else { + println!("Moved {} → {}", report.from, report.to); + print_rewrite_tally(report.links_rewritten, report.rewrote.len()); + } + } else { + let report = vault.move_note(from, to)?; + if cli.json { + print_json(&report)?; + } else { + println!("Moved {} → {}", report.from, report.to); + print_rewrite_tally(report.links_rewritten, report.rewrote.len()); + } + } + Ok(()) +} + +fn cmd_rm(cli: &Cli, target: &str, recursive: bool) -> Result<(), CliError> { + // A delete removes files and index rows but never rewrites a body + // (inbound links dangle, they aren't repaired) → **model-free**, like + // the desktop's delete; still an explicit vault, like every write. + let root = cli.require_vault(None)?; + let vault = open_vault(root, false)?; + // Kind dispatch (§9b #8), mirroring `mv`: an existing directory deletes + // as a folder — gated on --recursive, the CLI's stand-in for the + // desktop's confirm dialog — else the extension picks the file arm. + if is_dir_arg(root, target) { + if !recursive { + return Err(CliError::RecursiveRequired(target.to_string())); + } + let report = vault.delete_dir(target)?; + if cli.json { + print_json(&report)?; + } else { + println!( + "Deleted {}/ ({} note(s), {} file(s))", + report.dir, report.deleted_notes, report.deleted_resources + ); + print_dangled(&report.dangled); + } + } else if doc_kind(target) == DocKind::Resource { + let report = vault.delete_resource(target)?; + if cli.json { + print_json(&report)?; + } else { + println!("Deleted {}", report.path); + print_dangled(&report.dangled); + } + } else { + let report = vault.delete_note(target)?; + if cli.json { + print_json(&report)?; + } else { + println!("Deleted {}", report.path); + print_dangled(&report.dangled); + } + } + Ok(()) +} + +fn cmd_search(cli: &Cli, query: &str, limit: usize) -> Result<(), CliError> { + // Search embeds the query for the vector half → it needs the real model. + let vault = open_vault(cli.vault_or_cwd(), true)?; + let results = vault.search(query, limit)?; + if cli.json { + print_json(&results)?; + } else { + if results.is_empty() { + println!("No results."); + } else { + for r in &results { + let name = display_name(r.title.as_deref(), &r.path); + println!("{:.4} {name} ({})", r.score, r.path); + if !r.snippet.is_empty() { + println!(" {}", r.snippet); } - // Nudge toward the commit step, on stderr so stdout stays pure results. - eprintln!("Commit one with: b2 link {note} --type "); } } - Command::Link { - src, - dst, - edge_type, - explanation, - } => { - // Link writes the source note's frontmatter and re-projects it → require an - // explicit vault (no silent cwd), opening with the same real model the index - // was built with (like `add`/`mv`); a frontmatter-only edit won't re-embed. - let (vault, _semantic) = open_vault(cli.require_vault(None)?, true)?; - let report = vault.link(src, dst, edge_type, explanation.as_deref())?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&report)?); - } else if report.created { - println!( - "Linked {} —{}→ {}. Wrote the relation into the source note's frontmatter.", - report.src_path, report.relation, report.dst_path - ); - } else { - println!( - "Already linked {} —{}→ {}. Nothing changed.", - report.src_path, report.relation, report.dst_path - ); + // Honesty (never overstate): with the fake embedder the vector half + // isn't semantic. Under the real model it is, so no caveat. Kept on + // stderr so stdout stays pure results. + if use_fake_embedder() { + eprintln!( + "note: keyword (BM25) ranking is live; semantic ranking is off (fake embedder)." + ); + } + } + Ok(()) +} + +fn cmd_similar(cli: &Cli, note: &str, limit: usize) -> Result<(), CliError> { + // Candidate generation reads the *stored* vectors (no query embedding), so + // like `neighbors` it needs no live model — a prior `reindex` supplies them. + // Open with the fake; it's a pure, instant local read. + let vault = open_vault(cli.vault_or_cwd(), false)?; + let results = vault.similar(note, limit)?; + if cli.json { + print_json(&results)?; + } else if results.is_empty() { + println!( + "No similar notes. (If you haven't yet, run `b2 init` then `b2 reindex` so similarity is semantic.)" + ); + } else { + for r in &results { + let name = display_name(r.title.as_deref(), &r.path); + println!("{:.4} {name} ({})", r.score, r.path); + if !r.evidence.is_empty() { + println!(" {}", r.evidence); } } + // Nudge toward the commit step, on stderr so stdout stays pure results. + eprintln!("Commit one with: b2 link {note} --type "); } Ok(()) } -/// Open a vault with the appropriate embedder. Returns the vault and whether its -/// embedder is semantic (real model) — the caller uses that only for honest output. +fn cmd_link( + cli: &Cli, + src: &str, + dst: &str, + edge_type: &str, + explanation: Option<&str>, +) -> Result<(), CliError> { + // Link writes the source note's frontmatter and re-projects it → require an + // explicit vault (no silent cwd), opening with the same real model the index + // was built with (like `add`/`mv`); a frontmatter-only edit won't re-embed. + let vault = open_vault(cli.require_vault(None)?, true)?; + let report = vault.link(src, dst, edge_type, explanation)?; + if cli.json { + print_json(&report)?; + } else if report.created { + println!( + "Linked {} —{}→ {}. Wrote the relation into the source note's frontmatter.", + report.src_path, report.relation, report.dst_path + ); + } else { + println!( + "Already linked {} —{}→ {}. Nothing changed.", + report.src_path, report.relation, report.dst_path + ); + } + Ok(()) +} + +/// Open a vault with the appropriate embedder. /// /// `needs_semantic` commands (`reindex`, `search`) load the real [`LocalEmbedder`] /// from the shared cache and **fail fast** with "run `b2 init`" if it's absent. /// Pure-graph commands pass `false` and use the fake — no model required just to /// explore the graph. `B2_EMBEDDER=fake` forces the fake everywhere (offline/dev /// mode, and what the test suite runs under). -fn open_vault(root: &Path, needs_semantic: bool) -> Result<(Vault, bool), CliError> { +fn open_vault(root: &Path, needs_semantic: bool) -> Result { if needs_semantic && !use_fake_embedder() { let config = EmbedConfig::load()?; let embedder = LocalEmbedder::load(&config)?; - Ok(( - Vault::open_with_embedder(root, Box::new(embedder) as Box)?, - true, - )) + Ok(Vault::open_with_embedder( + root, + Box::new(embedder) as Box, + )?) } else { - Ok((Vault::open(root)?, false)) + Ok(Vault::open(root)?) } } fn use_fake_embedder() -> bool { - matches!(std::env::var("B2_EMBEDDER").ok().as_deref(), Some("fake")) + std::env::var_os("B2_EMBEDDER").is_some_and(|v| v == "fake") +} + +/// The presentation rule for naming a note: its title when it has one, else its +/// vault-relative path. +fn display_name<'a>(title: Option<&'a str>, path: &'a str) -> &'a str { + title.unwrap_or(path) +} + +/// The direction glyph: `→` for an outbound edge (this note → other), `←` inbound. +fn arrow(direction: &str) -> &'static str { + if direction == "outbound" { + "→" + } else { + "←" + } +} + +/// Append a resource line's decorations: the `(embed)` marker and the quoted caption. +fn decorate(line: &mut String, embed: bool, caption: Option<&str>) { + use std::fmt::Write as _; + if embed { + line.push_str(" (embed)"); + } + if let Some(c) = caption { + let _ = write!(line, " — \"{c}\""); + } +} + +/// Whether a `mv`/`rm` argument names an existing directory under `root` — the +/// kind-dispatch test that routes to the folder arm (a trailing `/` is tolerated). +fn is_dir_arg(root: &Path, arg: &str) -> bool { + root.join(arg.trim_end_matches('/')).is_dir() +} + +/// The `mv` tally, shared by its three arms: how many inbound link targets were +/// rewritten across how many files — or that nothing linked to the moved item. +fn print_rewrite_tally(links_rewritten: usize, rewrote_files: usize) { + if links_rewritten > 0 { + println!("Rewrote {links_rewritten} inbound link(s) across {rewrote_files} file(s)."); + } else { + println!("No inbound links to rewrite."); + } +} + +/// The `rm` tally, shared by its three arms: which surviving files' links now +/// dangle — or that none were affected. +fn print_dangled(dangled: &[String]) { + if dangled.is_empty() { + println!("No inbound links affected."); + } else { + println!( + "Links in {} file(s) now unresolved: {}", + dangled.len(), + dangled.join(", ") + ); + } } /// Path to the single-in-flight advisory lock for `reindex`, under the disposable @@ -1010,13 +1088,10 @@ fn cancel_reindex(root: &Path, json: bool) -> Result<(), CliError> { // `signalled`, not `cancelled`: the request landed; the run stops at its next // batch boundary and reports the partial work itself (honest tense, like the // dry-run's `would_*` keys). - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "signalled": true, - "pid": pid, - }))? - ); + print_json(&serde_json::json!({ + "signalled": true, + "pid": pid, + }))?; } else { println!( "Cancelling the reindex on this vault (pid {pid}). It stops after the current batch, leaving a consistent index — re-run `b2 reindex` to finish." @@ -1155,18 +1230,10 @@ fn user_message(err: &CliError) -> String { _ => "Something went wrong. Please check the vault path and try again.".to_string(), }; if std::env::var_os("B2_DEBUG").is_some() { - let detail = match err { - CliError::Core(e) => e.to_string(), - CliError::Embed(e) => e.to_string(), - CliError::Serde(e) => e.to_string(), - CliError::Io(e) => e.to_string(), - CliError::VaultRequired => err.to_string(), - CliError::ReindexRunning => err.to_string(), - CliError::NoReindexRunning => err.to_string(), - CliError::ReindexPidUnknown => err.to_string(), - CliError::RecursiveRequired(_) => err.to_string(), - CliError::StdinRequired => err.to_string(), - }; + // Every wrapper variant is `#[error(transparent)]` and every local variant + // carries its own `#[error("…")]` line, so the enum's own `Display` *is* the + // per-variant detail — no match needed. + let detail = err.to_string(); format!("{msg}\n(debug: {detail})") } else { msg From 3264a19b7f22921fc3d904a5eb931d284080cc9d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 04:15:43 +0000 Subject: [PATCH 3/4] desktop: posture-named opens and one home for host plumbing Pure refactor, IPC shapes and user-facing strings unchanged. The 23 call sites discarding open_vault's semantic flag become open_read / open_semantic, so a command's model posture reads in the call itself; the two byte-identical error-detail matches collapse to err.to_string() (Core/Embed are #[error(transparent)]); user_message trades its wildcard arm for the explicit Core|Embed pattern, so a new host variant fails to compile instead of silently degrading to "Something went wrong"; the stats ledger's serialize-and-write tail becomes write_ledger (parent creation stays record_to's alone); poisoned-lock recovery and its rationale live once in lock_recover; EmbedStat maps via From instead of a hand-copied closure; and ReindexGuard moves next to the AppState slot it guards. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WE6yzYZhgzwrwYpzaYzMs2 --- crates/b2-desktop/src/commands.rs | 75 +++++++++++++++---------------- crates/b2-desktop/src/error.rs | 31 +++++-------- crates/b2-desktop/src/main.rs | 40 ++++++++++++++--- crates/b2-desktop/src/stats.rs | 14 ++++-- crates/b2-desktop/src/watch.rs | 10 ++--- 5 files changed, 94 insertions(+), 76 deletions(-) diff --git a/crates/b2-desktop/src/commands.rs b/crates/b2-desktop/src/commands.rs index 95267b7..a5a356e 100644 --- a/crates/b2-desktop/src/commands.rs +++ b/crates/b2-desktop/src/commands.rs @@ -17,7 +17,7 @@ use crate::error::CmdError; use crate::watch::VaultWatcher; -use crate::{open_vault, AppState}; +use crate::{open_read, open_semantic, open_vault, AppState, ReindexGuard}; use b2_core::add::AddReport; use b2_core::ingest::ReindexProgress; use b2_core::vault::{ @@ -118,7 +118,7 @@ pub fn list_notes(state: State<'_, AppState>) -> Result, CmdErr /// per-kind composition the locked design prefers over a union type (research §9b #10). #[tauri::command(async)] pub fn list_resources(state: State<'_, AppState>) -> Result, CmdError> { - let (vault, _) = open_vault(state.inner(), false)?; + let vault = open_read(state.inner())?; Ok(vault.list_resources()?) } @@ -147,7 +147,7 @@ pub fn explain_resource( state: State<'_, AppState>, path: String, ) -> Result { - let (vault, _) = open_vault(state.inner(), false)?; + let vault = open_read(state.inner())?; Ok(vault.explain_resource(&path)?) } @@ -158,7 +158,7 @@ pub fn explain_resource( /// and hands the absolute path to the OS. #[tauri::command(async)] pub fn open_resource(state: State<'_, AppState>, path: String) -> Result<(), CmdError> { - let (vault, _) = open_vault(state.inner(), false)?; + let vault = open_read(state.inner())?; vault.explain_resource(&path)?; // inventory check: unknown paths refuse, never open let root = state.current_root().ok_or(CmdError::VaultRequired)?; tauri_plugin_opener::open_path(root.join(&path), None::<&str>) @@ -340,7 +340,7 @@ pub fn similar( note: String, limit: usize, ) -> Result, CmdError> { - let (vault, _) = open_vault(state.inner(), false)?; + let vault = open_read(state.inner())?; Ok(vault.similar(¬e, limit)?) } @@ -351,19 +351,19 @@ pub fn search( limit: usize, ) -> Result, CmdError> { // Semantic: the query is embedded, so this opens the real model (fail-fast if absent). - let (vault, _) = open_vault(state.inner(), true)?; + let vault = open_semantic(state.inner())?; Ok(vault.search(&query, limit)?) } #[tauri::command(async)] pub fn neighbors(state: State<'_, AppState>, note: String) -> Result, CmdError> { - let (vault, _) = open_vault(state.inner(), false)?; + let vault = open_read(state.inner())?; Ok(vault.neighbors(¬e)?) } #[tauri::command(async)] pub fn explain(state: State<'_, AppState>, note: String) -> Result { - let (vault, _) = open_vault(state.inner(), false)?; + let vault = open_read(state.inner())?; Ok(vault.explain(¬e)?) } @@ -376,7 +376,7 @@ pub fn link( explanation: Option, ) -> Result { // Re-projects the source note → opens the same real model the index was built with. - let (vault, _) = open_vault(state.inner(), true)?; + let vault = open_semantic(state.inner())?; Ok(vault.link(&src, &dst, &relation, explanation.as_deref())?) } @@ -489,6 +489,17 @@ pub struct EmbedStat { pub runs: u64, } +impl From<(String, crate::stats::ModelStat)> for EmbedStat { + fn from((model, s): (String, crate::stats::ModelStat)) -> Self { + Self { + model, + total_ms: s.total_ms, + chunks: s.chunks, + runs: s.runs, + } + } +} + /// The per-model embedding-time ledger (`stats.rs`) — what the Settings pane renders so a /// model swap can be judged on real speed. Infallible: no data / an unreadable ledger is /// an empty list, never an error (the totals are diagnostic, never load-bearing). @@ -496,12 +507,7 @@ pub struct EmbedStat { pub fn embed_stats() -> Vec { crate::stats::read_all() .into_iter() - .map(|(model, s)| EmbedStat { - model, - total_ms: s.total_ms, - chunks: s.chunks, - runs: s.runs, - }) + .map(EmbedStat::from) .collect() } @@ -518,19 +524,10 @@ pub fn menu_chords() -> Vec { crate::menu::chords() } -/// Releases the single-in-flight reindex slot on drop, so it is freed on **every** -/// exit path — normal return, an early `?` (e.g. model-not-provisioned), or a panic. -struct ReindexGuard<'a>(&'a AppState); -impl Drop for ReindexGuard<'_> { - fn drop(&mut self) { - self.0.finish_reindex(); - } -} - /// The testable core of `project`: one façade call over the fake vault (projection /// is model-free by construction — it never touches the embedding space). fn project_impl(state: &AppState) -> Result { - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; Ok(vault.project(false)?) } @@ -589,7 +586,7 @@ fn vault_info_impl(state: &AppState) -> Result { // Model-free read: open the fake vault only to count embedding coverage (#26). The // real model is never loaded here — `semantic` stays "is a model installed", while // `notes_embedded/total` is the precise fraction the UI flags keyword-only from. - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; let status = vault.embed_status()?; Ok(VaultInfo { root: root.display().to_string(), @@ -613,22 +610,22 @@ fn set_vault_root_impl(state: &AppState, root: &Path) -> Result Result { - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; Ok(vault.read(note)?) } fn list_notes_impl(state: &AppState) -> Result, CmdError> { - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; Ok(vault.list_notes()?) } fn list_dirs_impl(state: &AppState) -> Result, CmdError> { - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; Ok(vault.list_dirs()?) } fn create_dir_impl(state: &AppState, dir: &str) -> Result { - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; Ok(vault.create_dir(dir)?) } @@ -638,7 +635,7 @@ fn write_note_impl( body: &str, base_revision: &str, ) -> Result { - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; Ok(vault.write(note, body, base_revision)?) } @@ -648,17 +645,17 @@ fn write_frontmatter_impl( frontmatter: &str, base_revision: &str, ) -> Result { - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; Ok(vault.write_frontmatter(note, frontmatter, base_revision)?) } fn create_note_impl(state: &AppState, path: &str) -> Result { - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; Ok(vault.create_note(path)?) } fn move_note_impl(state: &AppState, note: &str, to: &str) -> Result { - let (vault, _) = open_vault(state, true)?; + let vault = open_semantic(state)?; Ok(vault.move_note(note, to)?) } @@ -667,27 +664,27 @@ fn move_resource_impl( path: &str, to: &str, ) -> Result { - let (vault, _) = open_vault(state, true)?; + let vault = open_semantic(state)?; Ok(vault.move_resource(path, to)?) } fn move_dir_impl(state: &AppState, from: &str, to: &str) -> Result { - let (vault, _) = open_vault(state, true)?; + let vault = open_semantic(state)?; Ok(vault.move_dir(from, to)?) } fn delete_note_impl(state: &AppState, note: &str) -> Result { - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; Ok(vault.delete_note(note)?) } fn delete_resource_impl(state: &AppState, path: &str) -> Result { - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; Ok(vault.delete_resource(path)?) } fn delete_dir_impl(state: &AppState, dir: &str) -> Result { - let (vault, _) = open_vault(state, false)?; + let vault = open_read(state)?; Ok(vault.delete_dir(dir)?) } diff --git a/crates/b2-desktop/src/error.rs b/crates/b2-desktop/src/error.rs index b1752a9..7c32b9f 100644 --- a/crates/b2-desktop/src/error.rs +++ b/crates/b2-desktop/src/error.rs @@ -142,18 +142,17 @@ pub fn user_message(err: &CmdError) -> String { CmdError::ReindexInFlight => { "A reindex is already in progress. Please wait for it to finish.".to_string() } - _ => "Something went wrong. Please check the vault and try again.".to_string(), + // Everything else in the two composed crates is an internal (sqlite/io/serde/…) + // the webview must never see. Spelled out rather than `_` so adding a CmdError + // variant fails to compile here instead of silently degrading to the catch-all. + CmdError::Core(_) | CmdError::Embed(_) => { + "Something went wrong. Please check the vault and try again.".to_string() + } }; if std::env::var_os("B2_DEBUG").is_some() { - let detail = match err { - CmdError::Core(e) => e.to_string(), - CmdError::Embed(e) => e.to_string(), - CmdError::VaultRequired - | CmdError::ReindexInFlight - | CmdError::OpenFailed(_) - | CmdError::UnsupportedLink(_) - | CmdError::ClipboardFailed(_) => err.to_string(), - }; + // `Core`/`Embed` are `#[error(transparent)]`, so `err` displays as its source — + // one `to_string` covers every variant. + let detail = err.to_string(); format!("{msg}\n(debug: {detail})") } else { msg @@ -168,15 +167,9 @@ pub fn user_message(err: &CmdError) -> String { /// every command error crosses to the webview — [`CmdError`]'s `Serialize` impl — so /// logging stays uniform and out of the dumb command handlers. fn log_internal(err: &CmdError) { - let detail = match err { - CmdError::Core(e) => e.to_string(), - CmdError::Embed(e) => e.to_string(), - CmdError::VaultRequired - | CmdError::ReindexInFlight - | CmdError::OpenFailed(_) - | CmdError::UnsupportedLink(_) - | CmdError::ClipboardFailed(_) => err.to_string(), - }; + // `Core`/`Embed` are `#[error(transparent)]`, so `err` displays as its source — + // one `to_string` covers every variant. + let detail = err.to_string(); eprintln!("[b2] command failed: {detail}"); } diff --git a/crates/b2-desktop/src/main.rs b/crates/b2-desktop/src/main.rs index 1c86f90..a270755 100644 --- a/crates/b2-desktop/src/main.rs +++ b/crates/b2-desktop/src/main.rs @@ -107,8 +107,8 @@ impl AppState { .is_ok() } - /// Release the reindex slot (always, even on error — see the RAII guard in - /// `commands.rs`). Idempotent. + /// Release the reindex slot (always, even on error — see [`ReindexGuard`]). + /// Idempotent. pub fn finish_reindex(&self) { self.reindex_running.store(false, Ordering::SeqCst); } @@ -152,15 +152,29 @@ impl AppState { } /// The critical sections here are a single clone or store — neither can panic — - /// so the lock can never be poisoned; recover the inner value rather than unwrap - /// (the no-panic rule) if a poison ever somehow occurs. + /// so the lock can never be poisoned; see [`lock_recover`]. fn lock_root(&self) -> std::sync::MutexGuard<'_, Option> { - self.root - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) + lock_recover(&self.root) } } +/// Releases the single-in-flight reindex slot on drop, so it is freed on **every** +/// exit path — normal return, an early `?` (e.g. model-not-provisioned), or a panic. +pub(crate) struct ReindexGuard<'a>(pub(crate) &'a AppState); +impl Drop for ReindexGuard<'_> { + fn drop(&mut self) { + self.0.finish_reindex(); + } +} + +/// Lock a mutex, recovering the inner value rather than unwrapping if the lock is ever +/// poisoned (the no-panic rule). Every mutex in this host guards a critical section of +/// a single clone/store/drop — none can panic, so poisoning is effectively impossible, +/// but the rule holds regardless if a poison ever somehow occurs. +pub(crate) fn lock_recover(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + /// Whether the deterministic fake embedder is forced (`B2_EMBEDDER=fake`) — the CLI's /// offline/dev switch, honored identically so the two adapters behave the same. fn use_fake_embedder() -> bool { @@ -185,6 +199,18 @@ pub fn open_vault(state: &AppState, needs_semantic: bool) -> Result<(Vault, bool } } +/// Read-path open: a fresh vault over the fake embedder (no model load), for commands +/// that never embed. [`open_vault`] with the semantic flag discarded. +pub fn open_read(state: &AppState) -> Result { + Ok(open_vault(state, false)?.0) +} + +/// Wants-the-real-model open: a fresh vault over the real embedder (fail-fast "run +/// `b2 init`" if absent), for commands that embed. [`open_vault`] with the flag discarded. +pub fn open_semantic(state: &AppState) -> Result { + Ok(open_vault(state, true)?.0) +} + /// Whether the real (semantic) embedder is available right now — mirrors the CLI: /// false under `B2_EMBEDDER=fake`, or if the model isn't provisioned yet. Used by /// `vault_info` to tell the UI whether semantic ranking is live, so the app can be diff --git a/crates/b2-desktop/src/stats.rs b/crates/b2-desktop/src/stats.rs index 110a3dd..9098ac8 100644 --- a/crates/b2-desktop/src/stats.rs +++ b/crates/b2-desktop/src/stats.rs @@ -64,6 +64,14 @@ fn read_from(path: &Path) -> StatsFile { .unwrap_or_default() } +/// Serialize the ledger and rewrite it at `path` — the shared write tail of +/// [`record_to`] and [`reset_in`]. Creating the parent dir stays the caller's job: +/// only [`record_to`] may create the file (a no-op reset must not). +fn write_ledger(path: &Path, file: &StatsFile) -> std::io::Result<()> { + let text = serde_json::to_string_pretty(file).map_err(std::io::Error::other)?; + std::fs::write(path, text) +} + /// Add one embed run's `(elapsed_ms, chunks)` to `model`'s running total. Best-effort: /// a missing data dir or a write failure is logged to stderr and swallowed — recording a /// measurement must never fail the embed the user actually asked for. @@ -89,8 +97,7 @@ fn record_to(path: &Path, model: &str, elapsed_ms: u64, chunks: u64) -> std::io: if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let text = serde_json::to_string_pretty(&file).map_err(std::io::Error::other)?; - std::fs::write(path, text) + write_ledger(path, &file) } /// Forget `model`'s accumulated total, so its bucket restarts from zero on the next @@ -119,8 +126,7 @@ fn reset_in(path: &Path, model: &str) -> std::io::Result<()> { if file.models.remove(model).is_none() { return Ok(()); // nothing recorded for this model — leave the file untouched } - let text = serde_json::to_string_pretty(&file).map_err(std::io::Error::other)?; - std::fs::write(path, text) + write_ledger(path, &file) } #[cfg(test)] diff --git a/crates/b2-desktop/src/watch.rs b/crates/b2-desktop/src/watch.rs index 0709c25..70a8748 100644 --- a/crates/b2-desktop/src/watch.rs +++ b/crates/b2-desktop/src/watch.rs @@ -68,14 +68,10 @@ impl VaultWatcher { } } - /// Recover the inner value rather than panic if the lock is ever poisoned — the - /// critical section is a single store/drop that can't panic, so poisoning is - /// effectively impossible, but the no-`unwrap` rule holds regardless (main.rs mirrors - /// this on its root mutex). + /// The critical section here is a single store/drop that can't panic, so the lock + /// can never be poisoned; see [`lock_recover`](crate::lock_recover). fn lock(&self) -> std::sync::MutexGuard<'_, Option> { - self.0 - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) + crate::lock_recover(&self.0) } } From abcc3947e817324f8a7dc131b287800234ffab6c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:41:45 +0000 Subject: [PATCH 4/4] core: is_hidden's doc stops overclaiming the note route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc-only. The predicate's comment said a dot-prefixed name is never vault material, but the ingest walk's note route deliberately keeps its historical behavior (a dot-prefixed .md still indexes — collect_vault_files says so). One definition of hidden, applied per route — say exactly that, so a reader (or a reviewer) can't take the predicate as a claim the walk doesn't make. Surfaced by PR #135 review feedback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WE6yzYZhgzwrwYpzaYzMs2 --- crates/b2-core/src/pathspec.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/b2-core/src/pathspec.rs b/crates/b2-core/src/pathspec.rs index 54604e1..33d190d 100644 --- a/crates/b2-core/src/pathspec.rs +++ b/crates/b2-core/src/pathspec.rs @@ -4,14 +4,17 @@ //! so each authoring op maps the reason onto its own [`crate::Error`] variant and //! its own user-facing phrasing, without the two coupling through a shared error. //! -//! Also home to the one **vault-membership rule** ([`is_hidden`]) both walks and -//! the validators share: a dot-prefixed name is never vault material. +//! Also home to the shared **hidden-path predicate** ([`is_hidden`]): the one +//! definition of what a dot-prefixed name is, which each walk applies per its +//! own routing rule. -/// Whether this walked entry's *name* is dot-prefixed — the vault-membership -/// rule the ingest walk and the folder walk both route on (`.b2/`, `.git/`, -/// `.DS_Store` are never vault material), and the same rule -/// [`normalize_rel_dir`] enforces on user input. One predicate so the walks -/// can't drift from each other or from the validator. +/// Whether this walked entry's *name* is dot-prefixed — the hidden-path +/// predicate shared by the folder walk (skips dot *directories*), the ingest +/// walk (skips dot directories and dot *resources*; the **note route +/// deliberately keeps its historical behavior** and still indexes a +/// dot-prefixed `.md` — see `collect_vault_files`), and the user-input +/// validator [`normalize_rel_dir`]. One predicate so those sites can't drift +/// on what "hidden" *means*, even where they apply it differently. pub(crate) fn is_hidden(path: &std::path::Path) -> bool { path.file_name() .and_then(|n| n.to_str())