From 33adb0b2f0f58c9f939bd3a51b861cbb4bce483f Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:02:53 -0500 Subject: [PATCH 1/4] feat(commands): add /link and /attach structured stash commands Adds a durable, structured store for links and file attachments: - New core module `stash`: SQLite-backed store at ~/.coven-code/stash.sqlite with kind, title, note, tags, project, and session metadata. Attachments are copied into ~/.coven-code/attachments// (full-uuid directory) so the stored copy survives the original moving or being deleted; lookups and removals are kind-scoped so /link cannot resolve or delete an attachment id (and vice versa). - New slash commands /link (add/list/search/remove) and /attach (add/list/search/show/remove) with quote-aware argument parsing so paths and titles containing spaces work; unquoted attach paths with spaces also resolve. - Both commands refuse to run in hosted review mode: the stash is a personal local store and must not mix into hosted tenant contexts. - Registered in the command registry and TUI autocomplete; documented in docs/commands.md. Verified with cargo fmt, check, clippy -D warnings, workspace tests, and a live TUI smoke test (quoted-path attach, kind-scoped removal, stored-copy cleanup). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/commands.md | 47 ++- src-rust/crates/commands/src/lib.rs | 485 ++++++++++++++++++++++++ src-rust/crates/core/src/lib.rs | 1 + src-rust/crates/core/src/stash.rs | 561 ++++++++++++++++++++++++++++ src-rust/crates/tui/src/app.rs | 5 + 5 files changed, 1098 insertions(+), 1 deletion(-) create mode 100644 src-rust/crates/core/src/stash.rs diff --git a/docs/commands.md b/docs/commands.md index 6e384bf..964d6da 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -11,7 +11,7 @@ This document is the reference for the visible slash commands available in Coven 3. [Model & Provider](#model--provider) — `/model`, `/providers`, `/connect`, `/thinking`, `/effort` 4. [Configuration & Settings](#configuration--settings) — `/config`, `/permissions`, `/hooks`, `/mcp` 5. [Code & Git](#code--git) — `/commit`, `/diff`, `/review`, `/init`, `/search` -6. [Search & Files](#search--files) +6. [Search & Files](#search--files) — `/link`, `/attach` 7. [Memory & Context](#memory--context) — `/memory`, `/usage`, `/status` 8. [Agents & Tasks](#agents--tasks) — `/familiar`, `/tasks`, `/coven goal` 9. [Planning & Review](#planning--review) — `/plan`, `ultraplan` (CLI) @@ -533,6 +533,51 @@ Analyze context window usage. Shows a breakdown of tokens consumed by system pro --- +### /link + +Save and manage links in the structured stash. Links are stored in `~/.coven-code/stash.sqlite` with title, note, tags, project, and session metadata. + +``` +/link [--title ...] [--note ...] [--tags a,b] — save a link +/link add [flags] — same as above +/link list [--tag ] [--project] — list saved links +/link search — search links +/link remove — delete a link +``` + +`/link list` shows each entry's short id, save date, title, URL, and tags. `--project` limits the listing to links saved from the current repository. The stash is a personal local store and is disabled in [hosted review mode](configuration#hosted-review-mode). + +``` +/link https://docs.rs/tokio --title Tokio docs --tags rust,async +/link list --tag rust +/link remove 1a2b3c4d +``` + +--- + +### /attach + +Save and manage file attachments in the structured stash. Files are copied into `~/.coven-code/attachments//`, so the stored copy survives even if the original file moves or is deleted. Metadata lives in `~/.coven-code/stash.sqlite` alongside saved links. + +``` +/attach [--title ...] [--note ...] [--tags a,b] — save a file +/attach add [flags] — same as above +/attach list [--tag ] [--project] — list attachments +/attach search — search attachments +/attach show — show stored/original paths +/attach remove — delete an attachment and its stored copy +``` + +Relative paths resolve against the current working directory; `~` expands to the home directory. Paths and flag values containing spaces can be quoted (`/attach "My File.pdf" --title "API spec"`); unquoted attach paths with spaces also work as long as no flags follow mid-path. Like `/link`, the stash is disabled in hosted review mode. + +``` +/attach ./design/spec.pdf --title API spec --tags design +/attach list --tag design +/attach show 1a2b3c4d +``` + +--- + ## Memory & Context ### /memory diff --git a/src-rust/crates/commands/src/lib.rs b/src-rust/crates/commands/src/lib.rs index 052d56b..b9174f5 100644 --- a/src-rust/crates/commands/src/lib.rs +++ b/src-rust/crates/commands/src/lib.rs @@ -282,6 +282,8 @@ pub struct ConnectCommand; pub struct FamiliarCommand; pub struct SearchCommand; pub struct ForkCommand; +pub struct LinkCommand; +pub struct AttachCommand; pub struct ManagedAgentsCommand; pub struct CovenCommand; pub struct NamedCommandAdapter { @@ -10197,6 +10199,417 @@ impl SlashCommand for CovenCommand { // Registry // --------------------------------------------------------------------------- +// ---- /link and /attach — structured link/attachment stash ------------------ + +/// Split an argument string into positional tokens and `--flag value...` pairs. +/// Tokens may be quoted with single or double quotes to include spaces +/// (`--title "API spec"`). Flag values run until the next `--flag` token. +/// `--tags` values are comma-separated. +fn parse_stash_args(args: &str) -> (Vec, BTreeMap) { + let mut positional = Vec::new(); + let mut flags = BTreeMap::new(); + let mut current_flag: Option = None; + let mut current_value: Vec = Vec::new(); + + for token in tokenize_stash_args(args) { + if let Some(name) = token.strip_prefix("--") { + if let Some(flag) = current_flag.take() { + flags.insert(flag, current_value.join(" ")); + current_value.clear(); + } + current_flag = Some(name.to_string()); + } else if current_flag.is_some() { + current_value.push(token); + } else { + positional.push(token); + } + } + if let Some(flag) = current_flag { + flags.insert(flag, current_value.join(" ")); + } + (positional, flags) +} + +/// Whitespace tokenizer with single/double-quote support. Quotes group words +/// into one token and are stripped; there is no escape syntax. +fn tokenize_stash_args(args: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut quote: Option = None; + + for ch in args.chars() { + match quote { + Some(q) if ch == q => quote = None, + Some(_) => current.push(ch), + None if ch == '"' || ch == '\'' => quote = Some(ch), + None if ch.is_whitespace() => { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } + None => current.push(ch), + } + } + if !current.is_empty() { + tokens.push(current); + } + tokens +} + +fn stash_tags_from_flags(flags: &BTreeMap) -> Vec { + flags + .get("tags") + .map(|raw| { + raw.split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +/// The project scope items are recorded under: the git repo root when inside +/// a repository, otherwise the working directory. +fn stash_project_scope(ctx: &CommandContext) -> String { + claurst_core::git_utils::get_repo_root(&ctx.working_dir) + .unwrap_or_else(|| ctx.working_dir.clone()) + .display() + .to_string() +} + +fn stash_hosted_refusal(ctx: &CommandContext) -> Option { + if ctx.config.hosted_review_enabled() { + Some(CommandResult::Message( + "The link/attachment stash is a personal local store and is disabled in \ + hosted review mode." + .to_string(), + )) + } else { + None + } +} + +fn format_stash_items(items: &[claurst_core::stash::StashItem], heading: &str) -> String { + if items.is_empty() { + return format!("{}: none.", heading); + } + let mut lines = vec![format!("{} ({}):", heading, items.len())]; + for item in items { + let date = + chrono::DateTime::::from_timestamp_millis(item.created_at_ms as i64) + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_default(); + let mut line = format!(" {} {}", item.short_id(), date); + if let Some(ref title) = item.title { + line.push_str(&format!(" {}", title)); + } + line.push_str(&format!(" {}", item.value)); + if !item.tags.is_empty() { + line.push_str(&format!(" [{}]", item.tags.join(", "))); + } + if let Some(ref note) = item.note { + line.push_str(&format!("\n note: {}", note)); + } + lines.push(line); + } + lines.join("\n") +} + +fn open_stash_store() -> Result { + claurst_core::stash::StashStore::open_default() + .map_err(|e| format!("Could not open stash: {}", e)) +} + +fn looks_like_url(token: &str) -> bool { + token.starts_with("http://") || token.starts_with("https://") || token.starts_with("www.") +} + +#[async_trait] +impl SlashCommand for LinkCommand { + fn name(&self) -> &str { + "link" + } + fn aliases(&self) -> Vec<&str> { + vec!["links"] + } + fn description(&self) -> &str { + "Save and manage links in the structured stash" + } + fn help(&self) -> &str { + "Usage:\n\ + /link [--title ...] [--note ...] [--tags a,b] — save a link\n\ + /link add [flags] — same as above\n\ + /link list [--tag ] [--project] — list saved links\n\ + /link search — search links\n\ + /link remove — delete a link\n\n\ + Links are stored in ~/.coven-code/stash.sqlite with title, note,\n\ + tags, project, and session metadata. Use the short id shown by\n\ + /link list to remove entries.\n\n\ + Examples:\n\ + /link https://docs.rs/tokio --title Tokio docs --tags rust,async\n\ + /link list --tag rust\n\ + /link remove 1a2b3c4d" + } + + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { + if let Some(refusal) = stash_hosted_refusal(ctx) { + return refusal; + } + let (positional, flags) = parse_stash_args(args); + let sub = positional.first().map(String::as_str).unwrap_or("list"); + + match sub { + "list" => { + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + let filter = claurst_core::stash::StashFilter { + kind: Some(claurst_core::stash::StashKind::Link), + tag: flags.get("tag").cloned(), + project: flags + .contains_key("project") + .then(|| stash_project_scope(ctx)), + }; + match store.list(&filter) { + Ok(items) => CommandResult::Message(format_stash_items(&items, "Links")), + Err(e) => CommandResult::Error(e.to_string()), + } + } + "search" => { + let term = positional[1..].join(" "); + if term.is_empty() { + return CommandResult::Message("Usage: /link search ".to_string()); + } + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + match store.search(&term, Some(claurst_core::stash::StashKind::Link)) { + Ok(items) => CommandResult::Message(format_stash_items( + &items, + &format!("Links matching '{}'", term), + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + "remove" | "rm" => { + let Some(id) = positional.get(1) else { + return CommandResult::Message("Usage: /link remove ".to_string()); + }; + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + match store.remove(id, Some(claurst_core::stash::StashKind::Link)) { + Ok(item) => CommandResult::Message(format!( + "Removed link {} ({}).", + item.short_id(), + item.value + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + _ => { + // `/link add ` or bare `/link `. + let url = if sub == "add" { + positional.get(1).cloned() + } else if looks_like_url(sub) { + Some(sub.to_string()) + } else { + None + }; + let Some(url) = url else { + return CommandResult::Message(self.help().to_string()); + }; + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + let tags = stash_tags_from_flags(&flags); + let project = stash_project_scope(ctx); + match store.add_link( + &url, + flags.get("title").map(String::as_str), + flags.get("note").map(String::as_str), + &tags, + Some(&project), + Some(&ctx.session_id), + ) { + Ok(item) => CommandResult::Message(format!( + "Saved link {} — {}", + item.short_id(), + item.value + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + } + } +} + +#[async_trait] +impl SlashCommand for AttachCommand { + fn name(&self) -> &str { + "attach" + } + fn aliases(&self) -> Vec<&str> { + vec!["attachments"] + } + fn description(&self) -> &str { + "Save and manage file attachments in the structured stash" + } + fn help(&self) -> &str { + "Usage:\n\ + /attach [--title ...] [--note ...] [--tags a,b] — save a file\n\ + /attach add [flags] — same as above\n\ + /attach list [--tag ] [--project] — list attachments\n\ + /attach search — search attachments\n\ + /attach show — show stored/original paths\n\ + /attach remove — delete an attachment\n\n\ + Files are copied into ~/.coven-code/attachments// so the stored\n\ + copy survives even if the original moves. Metadata lives in\n\ + ~/.coven-code/stash.sqlite alongside saved links.\n\n\ + Examples:\n\ + /attach ./design/spec.pdf --title API spec --tags design\n\ + /attach list --tag design\n\ + /attach show 1a2b3c4d" + } + + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { + if let Some(refusal) = stash_hosted_refusal(ctx) { + return refusal; + } + let (positional, flags) = parse_stash_args(args); + let sub = positional.first().map(String::as_str).unwrap_or("list"); + + match sub { + "list" => { + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + let filter = claurst_core::stash::StashFilter { + kind: Some(claurst_core::stash::StashKind::Attachment), + tag: flags.get("tag").cloned(), + project: flags + .contains_key("project") + .then(|| stash_project_scope(ctx)), + }; + match store.list(&filter) { + Ok(items) => CommandResult::Message(format_stash_items(&items, "Attachments")), + Err(e) => CommandResult::Error(e.to_string()), + } + } + "search" => { + let term = positional[1..].join(" "); + if term.is_empty() { + return CommandResult::Message("Usage: /attach search ".to_string()); + } + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + match store.search(&term, Some(claurst_core::stash::StashKind::Attachment)) { + Ok(items) => CommandResult::Message(format_stash_items( + &items, + &format!("Attachments matching '{}'", term), + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + "show" => { + let Some(id) = positional.get(1) else { + return CommandResult::Message("Usage: /attach show ".to_string()); + }; + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + match store.get(id, Some(claurst_core::stash::StashKind::Attachment)) { + Ok(item) => { + let mut out = + format!("Attachment {}\n stored: {}", item.short_id(), item.value); + if let Some(ref original) = item.original_path { + out.push_str(&format!("\n original: {}", original)); + } + if let Some(ref title) = item.title { + out.push_str(&format!("\n title: {}", title)); + } + if !item.tags.is_empty() { + out.push_str(&format!("\n tags: {}", item.tags.join(", "))); + } + if let Some(ref note) = item.note { + out.push_str(&format!("\n note: {}", note)); + } + CommandResult::Message(out) + } + Err(e) => CommandResult::Error(e.to_string()), + } + } + "remove" | "rm" => { + let Some(id) = positional.get(1) else { + return CommandResult::Message("Usage: /attach remove ".to_string()); + }; + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + match store.remove(id, Some(claurst_core::stash::StashKind::Attachment)) { + Ok(item) => CommandResult::Message(format!( + "Removed attachment {} and its stored copy.", + item.short_id() + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + _ => { + // Join the remaining positionals so unquoted paths with + // spaces still resolve (quoting also works). + let raw_path = if sub == "add" { + (positional.len() > 1).then(|| positional[1..].join(" ")) + } else { + Some(positional.join(" ")) + }; + let Some(raw_path) = raw_path.filter(|p| !p.is_empty()) else { + return CommandResult::Message(self.help().to_string()); + }; + let mut path = PathBuf::from(&raw_path); + if let Ok(stripped) = path.strip_prefix("~") { + if let Some(home) = dirs::home_dir() { + path = home.join(stripped); + } + } + if path.is_relative() { + path = ctx.working_dir.join(path); + } + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + let tags = stash_tags_from_flags(&flags); + let project = stash_project_scope(ctx); + match store.add_attachment( + &path, + &claurst_core::stash::StashStore::default_attachments_dir(), + flags.get("title").map(String::as_str), + flags.get("note").map(String::as_str), + &tags, + Some(&project), + Some(&ctx.session_id), + ) { + Ok(item) => CommandResult::Message(format!( + "Saved attachment {} — stored at {}", + item.short_id(), + item.value + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + } + } +} + static COMMANDS: Lazy>> = Lazy::new(|| { vec![ Box::new(HelpCommand), @@ -10308,6 +10721,9 @@ static COMMANDS: Lazy>> = Lazy::new(|| { Box::new(ManagedAgentsCommand), // Durable long-running goals Box::new(GoalCommand), + // Structured link/attachment stash + Box::new(LinkCommand), + Box::new(AttachCommand), // Coven daemon control surface (sessions, harness runs, rituals) Box::new(CovenCommand), ] @@ -10636,6 +11052,75 @@ mod tests { assert!(!all_commands().is_empty()); } + // ---- Stash command tests ------------------------------------------------- + + #[test] + fn parse_stash_args_splits_positionals_and_flags() { + let (positional, flags) = + parse_stash_args("add https://example.com --title Example site --tags a,b --note x y"); + assert_eq!(positional, vec!["add", "https://example.com"]); + assert_eq!(flags.get("title").map(String::as_str), Some("Example site")); + assert_eq!(flags.get("tags").map(String::as_str), Some("a,b")); + assert_eq!(flags.get("note").map(String::as_str), Some("x y")); + } + + #[test] + fn parse_stash_args_handles_valueless_flags() { + let (positional, flags) = parse_stash_args("list --project"); + assert_eq!(positional, vec!["list"]); + assert_eq!(flags.get("project").map(String::as_str), Some("")); + } + + #[test] + fn parse_stash_args_supports_quoted_tokens() { + let (positional, flags) = + parse_stash_args("add \"/tmp/My Docs/spec v2.pdf\" --title 'API spec (v2)' --tags a,b"); + assert_eq!(positional, vec!["add", "/tmp/My Docs/spec v2.pdf"]); + assert_eq!( + flags.get("title").map(String::as_str), + Some("API spec (v2)") + ); + assert_eq!(flags.get("tags").map(String::as_str), Some("a,b")); + } + + #[test] + fn parse_stash_args_quoted_value_keeps_dashes() { + let (_, flags) = parse_stash_args("x --note \"see --verbose flag\""); + assert_eq!( + flags.get("note").map(String::as_str), + Some("see --verbose flag") + ); + assert!(!flags.contains_key("verbose")); + } + + #[test] + fn stash_tags_are_trimmed_and_non_empty() { + let (_, flags) = parse_stash_args("x --tags a, b ,,c"); + assert_eq!(stash_tags_from_flags(&flags), vec!["a", "b", "c"]); + } + + #[tokio::test] + async fn link_command_refuses_hosted_mode() { + let mut ctx = make_ctx(); + ctx.config.hosted_review.enabled = true; + let result = LinkCommand.execute("list", &mut ctx).await; + match result { + CommandResult::Message(msg) => assert!(msg.contains("hosted review")), + other => panic!("expected refusal message, got {:?}", other), + } + } + + #[tokio::test] + async fn attach_command_refuses_hosted_mode() { + let mut ctx = make_ctx(); + ctx.config.hosted_review.enabled = true; + let result = AttachCommand.execute("list", &mut ctx).await; + match result { + CommandResult::Message(msg) => assert!(msg.contains("hosted review")), + other => panic!("expected refusal message, got {:?}", other), + } + } + #[test] fn test_all_commands_have_unique_names() { let mut names = std::collections::HashSet::new(); diff --git a/src-rust/crates/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index 1889671..e708259 100644 --- a/src-rust/crates/core/src/lib.rs +++ b/src-rust/crates/core/src/lib.rs @@ -49,6 +49,7 @@ pub mod claudemd; // Hosted review runtime isolation model. pub mod hosted_review; +pub mod stash; // OpenClaw installation/agent detection used by Coven integrations. pub mod openclaw_agent; diff --git a/src-rust/crates/core/src/stash.rs b/src-rust/crates/core/src/stash.rs new file mode 100644 index 0000000..3acc777 --- /dev/null +++ b/src-rust/crates/core/src/stash.rs @@ -0,0 +1,561 @@ +//! Stash — durable, structured storage for links and attachments. +//! +//! Backs the `/link` and `/attach` slash commands. Items are stored in +//! `~/.coven-code/stash.sqlite`; attachment files are copied into +//! `~/.coven-code/attachments//` so they survive the original +//! file moving or the session ending. + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StashKind { + Link, + Attachment, +} + +impl StashKind { + pub fn as_str(&self) -> &'static str { + match self { + StashKind::Link => "link", + StashKind::Attachment => "attachment", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "link" => Some(StashKind::Link), + "attachment" => Some(StashKind::Attachment), + _ => None, + } + } +} + +/// A single stashed link or attachment. +#[derive(Debug, Clone)] +pub struct StashItem { + pub id: String, + pub kind: StashKind, + /// The URL for links; the stored (copied) file path for attachments. + pub value: String, + /// Original source path for attachments (informational). + pub original_path: Option, + pub title: Option, + pub note: Option, + pub tags: Vec, + /// Project root the item was saved from. + pub project: Option, + pub session_id: Option, + pub created_at_ms: u64, +} + +impl StashItem { + /// Short display id (first 8 chars of the uuid). + pub fn short_id(&self) -> &str { + &self.id[..self.id.len().min(8)] + } +} + +/// Filters for listing stash items. +#[derive(Debug, Clone, Default)] +pub struct StashFilter { + pub kind: Option, + pub tag: Option, + pub project: Option, +} + +#[derive(Debug)] +pub enum StashError { + Db(String), + Io(String), + NotFound(String), + Ambiguous(String), +} + +impl std::fmt::Display for StashError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StashError::Db(msg) => write!(f, "stash database error: {}", msg), + StashError::Io(msg) => write!(f, "stash I/O error: {}", msg), + StashError::NotFound(id) => write!(f, "no stash item matches '{}'", id), + StashError::Ambiguous(id) => { + write!( + f, + "'{}' matches more than one stash item; use a longer id", + id + ) + } + } + } +} + +impl std::error::Error for StashError {} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +pub struct StashStore { + conn: rusqlite::Connection, +} + +impl StashStore { + /// Open (or create) a stash database. + pub fn open(db_path: &Path) -> Result { + let conn = + rusqlite::Connection::open(db_path).map_err(|e| StashError::Db(e.to_string()))?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS stash_items ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + value TEXT NOT NULL, + original_path TEXT, + title TEXT, + note TEXT, + tags TEXT NOT NULL DEFAULT '', + project TEXT, + session_id TEXT, + created_at_ms INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_stash_kind ON stash_items(kind); + CREATE INDEX IF NOT EXISTS idx_stash_project ON stash_items(project);", + ) + .map_err(|e| StashError::Db(e.to_string()))?; + Ok(Self { conn }) + } + + /// Default database path: `~/.coven-code/stash.sqlite`. + pub fn default_path() -> PathBuf { + crate::config::Settings::config_dir().join("stash.sqlite") + } + + /// Default directory for stored attachment copies. + pub fn default_attachments_dir() -> PathBuf { + crate::config::Settings::config_dir().join("attachments") + } + + /// Open using the default path (creates `~/.coven-code` if needed). + pub fn open_default() -> Result { + let path = Self::default_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| StashError::Io(e.to_string()))?; + } + Self::open(&path) + } + + fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + } + + /// Save a link. + pub fn add_link( + &self, + url: &str, + title: Option<&str>, + note: Option<&str>, + tags: &[String], + project: Option<&str>, + session_id: Option<&str>, + ) -> Result { + let item = StashItem { + id: uuid::Uuid::new_v4().simple().to_string(), + kind: StashKind::Link, + value: url.to_string(), + original_path: None, + title: title.map(str::to_string), + note: note.map(str::to_string), + tags: tags.to_vec(), + project: project.map(str::to_string), + session_id: session_id.map(str::to_string), + created_at_ms: Self::now_ms(), + }; + self.insert(&item)?; + Ok(item) + } + + /// Save an attachment: copies `src` into `attachments_dir//` + /// and records the stored copy. + // Metadata fields (title/note/tags/project/session) are individually + // optional; a params struct would just restate the StashItem shape. + #[allow(clippy::too_many_arguments)] + pub fn add_attachment( + &self, + src: &Path, + attachments_dir: &Path, + title: Option<&str>, + note: Option<&str>, + tags: &[String], + project: Option<&str>, + session_id: Option<&str>, + ) -> Result { + if !src.is_file() { + return Err(StashError::Io(format!( + "'{}' is not a readable file", + src.display() + ))); + } + let id = uuid::Uuid::new_v4().simple().to_string(); + let filename = src + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_else(|| "attachment".to_string()); + // Key the stored copy on the full id so two attachments can never + // collide on a shared directory, even with equal filenames. + let dest_dir = attachments_dir.join(&id); + std::fs::create_dir_all(&dest_dir).map_err(|e| StashError::Io(e.to_string()))?; + let dest = dest_dir.join(&filename); + if dest.exists() { + return Err(StashError::Io(format!( + "stored attachment path already exists: {}", + dest.display() + ))); + } + std::fs::copy(src, &dest).map_err(|e| StashError::Io(e.to_string()))?; + + let item = StashItem { + id, + kind: StashKind::Attachment, + value: dest.to_string_lossy().to_string(), + original_path: Some(src.to_string_lossy().to_string()), + title: title.map(str::to_string), + note: note.map(str::to_string), + tags: tags.to_vec(), + project: project.map(str::to_string), + session_id: session_id.map(str::to_string), + created_at_ms: Self::now_ms(), + }; + self.insert(&item)?; + Ok(item) + } + + fn insert(&self, item: &StashItem) -> Result<(), StashError> { + self.conn + .execute( + "INSERT INTO stash_items + (id, kind, value, original_path, title, note, tags, project, session_id, created_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + item.id, + item.kind.as_str(), + item.value, + item.original_path, + item.title, + item.note, + item.tags.join(","), + item.project, + item.session_id, + item.created_at_ms, + ], + ) + .map_err(|e| StashError::Db(e.to_string()))?; + Ok(()) + } + + /// List items matching the filter, newest first. + pub fn list(&self, filter: &StashFilter) -> Result, StashError> { + let mut items = self.query_all()?; + items.retain(|item| { + if let Some(kind) = filter.kind { + if item.kind != kind { + return false; + } + } + if let Some(ref tag) = filter.tag { + if !item.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) { + return false; + } + } + if let Some(ref project) = filter.project { + if item.project.as_deref() != Some(project.as_str()) { + return false; + } + } + true + }); + Ok(items) + } + + /// Case-insensitive substring search over value, title, note, and tags. + pub fn search( + &self, + term: &str, + kind: Option, + ) -> Result, StashError> { + let needle = term.to_lowercase(); + let mut items = self.query_all()?; + items.retain(|item| { + if let Some(k) = kind { + if item.kind != k { + return false; + } + } + item.value.to_lowercase().contains(&needle) + || item + .title + .as_deref() + .is_some_and(|t| t.to_lowercase().contains(&needle)) + || item + .note + .as_deref() + .is_some_and(|n| n.to_lowercase().contains(&needle)) + || item.tags.iter().any(|t| t.to_lowercase().contains(&needle)) + }); + Ok(items) + } + + /// Find a single item by full id or unique id prefix, optionally + /// restricted to one kind so `/link` cannot resolve an attachment id + /// (and vice versa). + pub fn get(&self, id_prefix: &str, kind: Option) -> Result { + let mut matches = self + .query_all()? + .into_iter() + .filter(|item| kind.is_none_or(|k| item.kind == k)) + .filter(|item| item.id.starts_with(id_prefix)); + match (matches.next(), matches.next()) { + (None, _) => Err(StashError::NotFound(id_prefix.to_string())), + (Some(item), None) => Ok(item), + (Some(_), Some(_)) => Err(StashError::Ambiguous(id_prefix.to_string())), + } + } + + /// Remove an item by id prefix (optionally kind-restricted). For + /// attachments the stored copy (and its per-item directory) is deleted + /// as well. + pub fn remove( + &self, + id_prefix: &str, + kind: Option, + ) -> Result { + let item = self.get(id_prefix, kind)?; + self.conn + .execute("DELETE FROM stash_items WHERE id = ?1", [&item.id]) + .map_err(|e| StashError::Db(e.to_string()))?; + if item.kind == StashKind::Attachment { + let stored = Path::new(&item.value); + let _ = std::fs::remove_file(stored); + if let Some(dir) = stored.parent() { + let _ = std::fs::remove_dir(dir); + } + } + Ok(item) + } + + fn query_all(&self) -> Result, StashError> { + let mut stmt = self + .conn + .prepare( + "SELECT id, kind, value, original_path, title, note, tags, project, + session_id, created_at_ms + FROM stash_items ORDER BY created_at_ms DESC", + ) + .map_err(|e| StashError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + let kind_str: String = row.get(1)?; + let tags_str: String = row.get(6)?; + Ok(StashItem { + id: row.get(0)?, + kind: StashKind::parse(&kind_str).unwrap_or(StashKind::Link), + value: row.get(2)?, + original_path: row.get(3)?, + title: row.get(4)?, + note: row.get(5)?, + tags: tags_str + .split(',') + .filter(|t| !t.is_empty()) + .map(str::to_string) + .collect(), + project: row.get(7)?, + session_id: row.get(8)?, + created_at_ms: row.get(9)?, + }) + }) + .map_err(|e| StashError::Db(e.to_string()))?; + let mut items = Vec::new(); + for row in rows { + items.push(row.map_err(|e| StashError::Db(e.to_string()))?); + } + Ok(items) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_store() -> (tempfile::TempDir, StashStore) { + let dir = tempfile::tempdir().unwrap(); + let store = StashStore::open(&dir.path().join("stash.sqlite")).unwrap(); + (dir, store) + } + + #[test] + fn add_and_list_links() { + let (_dir, store) = temp_store(); + store + .add_link( + "https://example.com/a", + Some("Example A"), + None, + &["docs".to_string()], + Some("/repo"), + Some("sess-1"), + ) + .unwrap(); + store + .add_link("https://example.com/b", None, None, &[], None, None) + .unwrap(); + + let all = store.list(&StashFilter::default()).unwrap(); + assert_eq!(all.len(), 2); + + let tagged = store + .list(&StashFilter { + tag: Some("docs".to_string()), + ..Default::default() + }) + .unwrap(); + assert_eq!(tagged.len(), 1); + assert_eq!(tagged[0].value, "https://example.com/a"); + assert_eq!(tagged[0].title.as_deref(), Some("Example A")); + } + + #[test] + fn add_attachment_copies_file_and_remove_cleans_up() { + let (dir, store) = temp_store(); + let src = dir.path().join("notes.txt"); + std::fs::write(&src, "hello").unwrap(); + let attachments = dir.path().join("attachments"); + + let item = store + .add_attachment(&src, &attachments, None, None, &[], None, None) + .unwrap(); + let stored = PathBuf::from(&item.value); + assert!(stored.exists()); + assert_eq!(std::fs::read_to_string(&stored).unwrap(), "hello"); + assert_eq!(item.original_path.as_deref(), Some(src.to_str().unwrap())); + + // Source can vanish; the stored copy remains. + std::fs::remove_file(&src).unwrap(); + assert!(stored.exists()); + + let removed = store + .remove(item.short_id(), Some(StashKind::Attachment)) + .unwrap(); + assert_eq!(removed.id, item.id); + assert!(!stored.exists()); + assert!(store.list(&StashFilter::default()).unwrap().is_empty()); + } + + #[test] + fn attachment_of_missing_file_errors() { + let (dir, store) = temp_store(); + let err = store + .add_attachment( + &dir.path().join("nope.bin"), + &dir.path().join("attachments"), + None, + None, + &[], + None, + None, + ) + .unwrap_err(); + assert!(matches!(err, StashError::Io(_))); + } + + #[test] + fn search_matches_title_note_tags_and_value() { + let (_dir, store) = temp_store(); + store + .add_link( + "https://docs.rs/rusqlite", + Some("Rusqlite docs"), + Some("sqlite bindings"), + &["rust".to_string()], + None, + None, + ) + .unwrap(); + store + .add_link("https://example.com", None, None, &[], None, None) + .unwrap(); + + assert_eq!(store.search("rusqlite", None).unwrap().len(), 1); + assert_eq!(store.search("BINDINGS", None).unwrap().len(), 1); + assert_eq!(store.search("rust", None).unwrap().len(), 1); + assert_eq!(store.search("zzz", None).unwrap().len(), 0); + } + + #[test] + fn get_by_prefix_and_ambiguity() { + let (_dir, store) = temp_store(); + let a = store + .add_link("https://a.example", None, None, &[], None, None) + .unwrap(); + store + .add_link("https://b.example", None, None, &[], None, None) + .unwrap(); + + assert_eq!(store.get(&a.id[..8], None).unwrap().id, a.id); + assert!(matches!( + store.get("zzzzzz", None), + Err(StashError::NotFound(_)) + )); + // Empty prefix matches everything → ambiguous. + assert!(matches!(store.get("", None), Err(StashError::Ambiguous(_)))); + } + + #[test] + fn kind_scoped_lookup_cannot_cross_kinds() { + let (dir, store) = temp_store(); + let link = store + .add_link("https://a.example", None, None, &[], None, None) + .unwrap(); + let src = dir.path().join("doc.txt"); + std::fs::write(&src, "doc").unwrap(); + let attachment = store + .add_attachment( + &src, + &dir.path().join("attachments"), + None, + None, + &[], + None, + None, + ) + .unwrap(); + + // A link lookup must not resolve an attachment id, and removing via + // the wrong kind must not delete the stored file. + assert!(matches!( + store.get(&attachment.id, Some(StashKind::Link)), + Err(StashError::NotFound(_)) + )); + assert!(matches!( + store.remove(&attachment.id, Some(StashKind::Link)), + Err(StashError::NotFound(_)) + )); + assert!(PathBuf::from(&attachment.value).exists()); + + assert!(matches!( + store.get(&link.id, Some(StashKind::Attachment)), + Err(StashError::NotFound(_)) + )); + assert_eq!( + store + .get(&attachment.id, Some(StashKind::Attachment)) + .unwrap() + .id, + attachment.id + ); + } +} diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index e803b90..a51db4e 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -56,6 +56,10 @@ pub const PROMPT_SLASH_COMMANDS: &[(&str, &str)] = &[ "accounts", "List stored accounts; `dedupe` collapses duplicate profiles", ), + ( + "attach", + "Save and manage file attachments in the structured stash", + ), ("chrome", "Browser automation via Chrome DevTools Protocol"), ("clear", "Clear the conversation transcript"), ( @@ -99,6 +103,7 @@ pub const PROMPT_SLASH_COMMANDS: &[(&str, &str)] = &[ "learn", "Codify the script/workflow we just built into a reusable skill", ), + ("link", "Save and manage links in the structured stash"), ("login", "Log in, switch accounts, or refresh provider auth"), ("logout", "Log out of Coven Code"), ("mcp", "Browse configured MCP servers"), From 6a0cddfe687af87e3459be1ab500030686472be0 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:16:24 -0500 Subject: [PATCH 2/4] test(tui): anchor command palette case on entries stable under new commands /attach now sorts first in the palette, pushing /config below the visible window on the initial capture. Anchor the open check on /clear and assert /attach, /clear, and /compact instead; the /config filter step still covers /config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/tui-tests/cases/03_command_palette.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/tui-tests/cases/03_command_palette.sh b/scripts/tui-tests/cases/03_command_palette.sh index 1dfd91f..fe42992 100644 --- a/scripts/tui-tests/cases/03_command_palette.sh +++ b/scripts/tui-tests/cases/03_command_palette.sh @@ -21,10 +21,11 @@ tc_palette() { # viewport shows a handful of rows, so entries further down (e.g. /config) # scroll out of view as new commands are registered. assert_contains "$s" "/accounts" "palette lists /accounts" + assert_contains "$s" "/attach" "palette lists /attach" assert_contains "$s" "/chrome" "palette lists /chrome" assert_contains "$s" "/clear" "palette lists /clear" - # Filtering narrows the list: "/config" should keep /config, drop /clear. + # Filtering narrows the list: "/config" should surface /config, drop /clear. tui_type "config" # Wait for the filter to take effect (a non-matching entry disappears). wait_absent "/clear" 5 From 7873609bb3b7ce2d3e3341127e642b7531241a0a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:20:00 -0500 Subject: [PATCH 3/4] fix(core): harden stash removal ordering and orphan cleanup Review follow-ups on the /link //attach stash: - add_attachment removes the stored copy if the DB insert fails, so a locked/full database cannot leave orphaned files behind. - remove() deletes the stored copy before the DB row; a filesystem failure keeps the row for retry, while an already-missing copy does not block removal. - Fix the hosted-review docs link to use the ./configuration.md form. - Add an end-to-end /link //attach round-trip test against a temp home covering quoted paths with spaces, kind-scoped removal, and stored copy cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/commands.md | 2 +- src-rust/crates/commands/src/lib.rs | 95 +++++++++++++++++++++++++++++ src-rust/crates/core/src/stash.rs | 29 +++++++-- 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 964d6da..c3caf71 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -545,7 +545,7 @@ Save and manage links in the structured stash. Links are stored in `~/.coven-cod /link remove — delete a link ``` -`/link list` shows each entry's short id, save date, title, URL, and tags. `--project` limits the listing to links saved from the current repository. The stash is a personal local store and is disabled in [hosted review mode](configuration#hosted-review-mode). +`/link list` shows each entry's short id, save date, title, URL, and tags. `--project` limits the listing to links saved from the current repository. The stash is a personal local store and is disabled in [hosted review mode](./configuration.md#hosted-review-mode). ``` /link https://docs.rs/tokio --title Tokio docs --tags rust,async diff --git a/src-rust/crates/commands/src/lib.rs b/src-rust/crates/commands/src/lib.rs index b9174f5..f8d16d8 100644 --- a/src-rust/crates/commands/src/lib.rs +++ b/src-rust/crates/commands/src/lib.rs @@ -11121,6 +11121,101 @@ mod tests { } } + fn message_text(result: CommandResult) -> String { + match result { + CommandResult::Message(msg) => msg, + other => panic!("expected message, got {:?}", other), + } + } + + #[tokio::test] + async fn link_and_attach_round_trip_against_temp_home() { + let _guard = CommandEnvGuard::with_coven_home(Some("tester")); + let mut ctx = make_ctx(); + + // Save and list a link. + let saved = message_text( + LinkCommand + .execute( + "https://example.com/docs --title \"Example docs\" --tags demo", + &mut ctx, + ) + .await, + ); + assert!(saved.contains("Saved link"), "{saved}"); + let listing = message_text(LinkCommand.execute("list --tag demo", &mut ctx).await); + assert!(listing.contains("https://example.com/docs"), "{listing}"); + assert!(listing.contains("Example docs"), "{listing}"); + + // Attach a file whose path contains spaces (quoted). + let files = tempfile::tempdir().unwrap(); + let src = files.path().join("my spec.txt"); + std::fs::write(&src, "attached-content").unwrap(); + let saved = message_text( + AttachCommand + .execute( + &format!("\"{}\" --title \"API spec\"", src.display()), + &mut ctx, + ) + .await, + ); + assert!(saved.contains("Saved attachment"), "{saved}"); + // "Saved attachment — stored at " + let attachment_id = saved + .split_whitespace() + .nth(2) + .map(str::to_string) + .unwrap_or_default(); + let stored_path = saved + .split(" stored at ") + .nth(1) + .map(str::trim) + .map(std::path::PathBuf::from) + .unwrap_or_default(); + assert!(stored_path.exists(), "stored copy missing: {saved}"); + assert_eq!( + std::fs::read_to_string(&stored_path).unwrap(), + "attached-content" + ); + + // Kind scoping: /link remove cannot resolve the attachment id. + let cross = LinkCommand + .execute(&format!("remove {attachment_id}"), &mut ctx) + .await; + assert!( + matches!(cross, CommandResult::Error(_)), + "cross-kind removal must fail, got {:?}", + cross + ); + assert!(stored_path.exists()); + + // /attach remove deletes the row and the stored copy. + let removed = message_text( + AttachCommand + .execute(&format!("remove {attachment_id}"), &mut ctx) + .await, + ); + assert!(removed.contains("Removed attachment"), "{removed}"); + assert!(!stored_path.exists()); + + // /link remove cleans up the link too. + let listing = message_text(LinkCommand.execute("list", &mut ctx).await); + let link_id = listing + .lines() + .nth(1) + .and_then(|line| line.split_whitespace().next()) + .map(str::to_string) + .unwrap_or_default(); + let removed = message_text( + LinkCommand + .execute(&format!("remove {link_id}"), &mut ctx) + .await, + ); + assert!(removed.contains("Removed link"), "{removed}"); + let listing = message_text(LinkCommand.execute("list", &mut ctx).await); + assert!(listing.contains("none"), "{listing}"); + } + #[test] fn test_all_commands_have_unique_names() { let mut names = std::collections::HashSet::new(); diff --git a/src-rust/crates/core/src/stash.rs b/src-rust/crates/core/src/stash.rs index 3acc777..6b546a0 100644 --- a/src-rust/crates/core/src/stash.rs +++ b/src-rust/crates/core/src/stash.rs @@ -231,7 +231,13 @@ impl StashStore { session_id: session_id.map(str::to_string), created_at_ms: Self::now_ms(), }; - self.insert(&item)?; + // Don't leave an orphaned stored copy behind if the row can't be + // recorded (locked/full database). + if let Err(err) = self.insert(&item) { + let _ = std::fs::remove_file(&dest); + let _ = std::fs::remove_dir(&dest_dir); + return Err(err); + } Ok(item) } @@ -328,23 +334,34 @@ impl StashStore { /// Remove an item by id prefix (optionally kind-restricted). For /// attachments the stored copy (and its per-item directory) is deleted - /// as well. + /// first; a filesystem failure keeps the row so the removal can be + /// retried, while an already-missing stored copy does not block it. pub fn remove( &self, id_prefix: &str, kind: Option, ) -> Result { let item = self.get(id_prefix, kind)?; - self.conn - .execute("DELETE FROM stash_items WHERE id = ?1", [&item.id]) - .map_err(|e| StashError::Db(e.to_string()))?; if item.kind == StashKind::Attachment { let stored = Path::new(&item.value); - let _ = std::fs::remove_file(stored); + match std::fs::remove_file(stored) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(StashError::Io(format!( + "could not delete stored copy {}: {}", + stored.display(), + e + ))) + } + } if let Some(dir) = stored.parent() { let _ = std::fs::remove_dir(dir); } } + self.conn + .execute("DELETE FROM stash_items WHERE id = ?1", [&item.id]) + .map_err(|e| StashError::Db(e.to_string()))?; Ok(item) } From 04f91f3d6ac9dd47848702f401609f128bdf3e1b Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:00:11 -0500 Subject: [PATCH 4/4] test(tui): make osc8 scan tests independent of the hyperlink env gate scan_buffer_for_urls consults COVEN_CODE_NO_HYPERLINKS, and enabled_respects_env_var mutates that variable while sibling osc8 tests run in parallel, so scan assertions randomly saw an empty hit list. Point the scan tests at the env-independent scan_buffer; the env gate keeps its own dedicated test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src-rust/crates/tui/src/osc8.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src-rust/crates/tui/src/osc8.rs b/src-rust/crates/tui/src/osc8.rs index 9a6bcf1..875f2cf 100644 --- a/src-rust/crates/tui/src/osc8.rs +++ b/src-rust/crates/tui/src/osc8.rs @@ -203,6 +203,11 @@ mod tests { use ratatui::layout::Rect; use ratatui::style::Style; + // Scan tests call the env-independent `scan_buffer` directly: + // `scan_buffer_for_urls` consults COVEN_CODE_NO_HYPERLINKS, and + // `enabled_respects_env_var` mutates that variable while sibling tests + // run in parallel, which made these tests flaky. + fn buffer_with(lines: &[&str]) -> Buffer { let h = lines.len() as u16; let w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16; @@ -216,7 +221,7 @@ mod tests { #[test] fn detects_simple_http_url() { let buf = buffer_with(&["Visit https://example.com today"]); - let hits = scan_buffer_for_urls(&buf); + let hits = scan_buffer(&buf); assert_eq!(hits.len(), 1); assert_eq!(hits[0].display, "https://example.com"); assert_eq!(hits[0].url, "https://example.com"); @@ -227,7 +232,7 @@ mod tests { #[test] fn strips_trailing_period_and_paren() { let buf = buffer_with(&["See (https://example.com)."]); - let hits = scan_buffer_for_urls(&buf); + let hits = scan_buffer(&buf); assert_eq!(hits.len(), 1, "hits: {hits:?}"); assert_eq!(hits[0].display, "https://example.com"); } @@ -235,7 +240,7 @@ mod tests { #[test] fn keeps_balanced_paren_inside_url() { let buf = buffer_with(&["see https://en.wikipedia.org/wiki/Foo_(bar) ok"]); - let hits = scan_buffer_for_urls(&buf); + let hits = scan_buffer(&buf); assert_eq!(hits.len(), 1); assert_eq!(hits[0].display, "https://en.wikipedia.org/wiki/Foo_(bar)"); } @@ -243,7 +248,7 @@ mod tests { #[test] fn detects_www_and_normalizes_to_https() { let buf = buffer_with(&["go to www.example.com now"]); - let hits = scan_buffer_for_urls(&buf); + let hits = scan_buffer(&buf); assert_eq!(hits.len(), 1); assert_eq!(hits[0].display, "www.example.com"); assert_eq!(hits[0].url, "https://www.example.com"); @@ -252,14 +257,14 @@ mod tests { #[test] fn no_urls_no_hits() { let buf = buffer_with(&["just some text without urls"]); - let hits = scan_buffer_for_urls(&buf); + let hits = scan_buffer(&buf); assert!(hits.is_empty()); } #[test] fn two_urls_one_line() { let buf = buffer_with(&["a https://one.test and https://two.test x"]); - let hits = scan_buffer_for_urls(&buf); + let hits = scan_buffer(&buf); assert_eq!(hits.len(), 2); assert_eq!(hits[0].display, "https://one.test"); assert_eq!(hits[1].display, "https://two.test"); @@ -273,7 +278,7 @@ mod tests { "https://opencoven.github.io/coven-code/session/#c2cc4dd0ae0d3fa6dc7ab21f2a79d7a1"; let line = format!("Share URL: {url}"); let buf = buffer_with(&[&line]); - let hits = scan_buffer_for_urls(&buf); + let hits = scan_buffer(&buf); assert_eq!(hits.len(), 1); assert_eq!(hits[0].display, url); }