From 0311f8252d36844e3911fed48460f259444d129b Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Fri, 3 Jul 2026 12:49:53 +0800 Subject: [PATCH 01/10] feat(rust): extract tauri-free core crate and agent client registry Move the scanner, git layer, workspace resolution, write layer, and error model into docsreader-core so the GUI and the MCP server share one implementation without the MCP binary compiling tauri. Tauri commands live in tauri_api; agents/ adds the MCP client registry (Claude Code, Cursor, Windsurf, VS Code, Codex) with non-destructive JSON/TOML config merges. --- src-tauri/.gitignore | 3 + src-tauri/Cargo.lock | 206 +++++- src-tauri/Cargo.toml | 15 +- src-tauri/core/Cargo.toml | 20 + src-tauri/core/src/delete.rs | 43 ++ src-tauri/core/src/error.rs | 82 +++ src-tauri/core/src/frontmatter.rs | 125 ++++ src-tauri/core/src/git.rs | 325 +++++++++ src-tauri/core/src/lib.rs | 15 + src-tauri/core/src/links.rs | 127 ++++ src-tauri/core/src/memory.rs | 326 +++++++++ src-tauri/core/src/path_guard.rs | 81 +++ src-tauri/core/src/read.rs | 400 +++++++++++ src-tauri/core/src/rename.rs | 137 ++++ src-tauri/core/src/scan.rs | 391 +++++++++++ src-tauri/core/src/slug.rs | 67 ++ src-tauri/core/src/tasks.rs | 465 +++++++++++++ src-tauri/core/src/update.rs | 146 ++++ src-tauri/core/src/workspace/init.rs | 244 +++++++ src-tauri/core/src/workspace/migrate.rs | 148 ++++ src-tauri/core/src/workspace/mod.rs | 129 ++++ src-tauri/core/src/workspace/registry.rs | 113 +++ src-tauri/core/src/workspace/resolve.rs | 257 +++++++ src-tauri/core/src/write.rs | 586 ++++++++++++++++ src-tauri/core/tests/welcome_bundle.rs | 44 ++ src-tauri/src/agents/config.rs | 183 +++++ src-tauri/src/agents/mod.rs | 296 ++++++++ src-tauri/src/lib.rs | 832 +---------------------- src-tauri/src/tauri_api/mod.rs | 114 ++++ 29 files changed, 5070 insertions(+), 850 deletions(-) create mode 100644 src-tauri/core/Cargo.toml create mode 100644 src-tauri/core/src/delete.rs create mode 100644 src-tauri/core/src/error.rs create mode 100644 src-tauri/core/src/frontmatter.rs create mode 100644 src-tauri/core/src/git.rs create mode 100644 src-tauri/core/src/lib.rs create mode 100644 src-tauri/core/src/links.rs create mode 100644 src-tauri/core/src/memory.rs create mode 100644 src-tauri/core/src/path_guard.rs create mode 100644 src-tauri/core/src/read.rs create mode 100644 src-tauri/core/src/rename.rs create mode 100644 src-tauri/core/src/scan.rs create mode 100644 src-tauri/core/src/slug.rs create mode 100644 src-tauri/core/src/tasks.rs create mode 100644 src-tauri/core/src/update.rs create mode 100644 src-tauri/core/src/workspace/init.rs create mode 100644 src-tauri/core/src/workspace/migrate.rs create mode 100644 src-tauri/core/src/workspace/mod.rs create mode 100644 src-tauri/core/src/workspace/registry.rs create mode 100644 src-tauri/core/src/workspace/resolve.rs create mode 100644 src-tauri/core/src/write.rs create mode 100644 src-tauri/core/tests/welcome_bundle.rs create mode 100644 src-tauri/src/agents/config.rs create mode 100644 src-tauri/src/agents/mod.rs create mode 100644 src-tauri/src/tauri_api/mod.rs diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd68..e8eed13 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -5,3 +5,6 @@ # Generated by Tauri # will have schema files for capabilities auto-completion /gen/schemas + +# Sidecar staging for bundle.externalBin (built by scripts/build-sidecar.ts) +/binaries/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d384ab4..1ddbc3d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -455,9 +455,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", @@ -750,7 +750,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -803,10 +803,9 @@ dependencies = [ name = "docsreader" version = "0.5.1" dependencies = [ - "rayon", + "docsreader-core", "serde", "serde_json", - "serde_yaml", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -815,10 +814,40 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-store", "tauri-plugin-updater", + "tempfile", + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "docsreader-core" +version = "0.6.0" +dependencies = [ + "chrono", + "rayon", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_yaml", "tokio", "walkdir", ] +[[package]] +name = "docsreader-mcp" +version = "0.1.0" +dependencies = [ + "chrono", + "docsreader-core", + "rmcp", + "schemars 1.2.1", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -962,7 +991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1110,6 +1139,21 @@ dependencies = [ "libc", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1117,6 +1161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1184,6 +1229,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2049,6 +2095,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2154,6 +2206,15 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2221,7 +2282,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2295,6 +2356,15 @@ dependencies = [ "serde", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2648,6 +2718,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2852,7 +2928,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -3099,6 +3175,42 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52d21e5b342699bc4de690e6104fc4e43255e4e8420ff0f2cbb963aac09da6f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "rmcp-macros" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c68cec74c5b3ac73ff46375ae49e161637bda80bba70f0d5db641583bb308ee" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3124,7 +3236,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3180,7 +3292,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3238,7 +3350,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -3263,8 +3375,10 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.2.1", "serde", "serde_json", ] @@ -3281,6 +3395,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3394,10 +3520,11 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -3520,6 +3647,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3583,7 +3719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4174,7 +4310,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4227,6 +4363,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -4270,9 +4415,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4413,13 +4558,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.2", ] @@ -4514,6 +4660,22 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + [[package]] name = "tray-icon" version = "0.23.1" @@ -4533,7 +4695,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4562,7 +4724,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4995,7 +5157,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b880bf1..6f22dae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,14 +20,19 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["preserve_order"] } tauri-plugin-dialog = "2" tauri-plugin-fs = { version = "2", features = ["watch"] } tauri-plugin-store = "2" -walkdir = "2" -serde_yaml = "0.9" -rayon = "1.12.0" tauri-plugin-updater = "2" -tokio = { version = "1.52.2", features = ["process", "time"] } tauri-plugin-process = "2.3.1" +docsreader-core = { path = "core" } +toml_edit = "0.25.12" + +[workspace] +members = ["core", "mcp"] +resolver = "2" + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/src-tauri/core/Cargo.toml b/src-tauri/core/Cargo.toml new file mode 100644 index 0000000..08eac84 --- /dev/null +++ b/src-tauri/core/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "docsreader-core" +version = "0.6.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +walkdir = "2" +rayon = "1.12.0" +tokio = { version = "1.52.2", features = ["process", "time", "io-util", "macros"] } +serde_json = "1.0.150" +chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } +schemars = { version = "1.2.1", optional = true } + +[dev-dependencies] +tokio = { version = "1.52.2", features = ["rt", "rt-multi-thread", "macros"] } + +[features] +schemars = ["dep:schemars"] diff --git a/src-tauri/core/src/delete.rs b/src-tauri/core/src/delete.rs new file mode 100644 index 0000000..e376086 --- /dev/null +++ b/src-tauri/core/src/delete.rs @@ -0,0 +1,43 @@ +use std::path::Path; + +use crate::error::CoreError; +use crate::write::{locate_doc, stage_in_git, WrittenDoc}; + +/// Permanent removal; archive_doc_core is the soft alternative. +pub async fn delete_doc_core(root: &Path, doc_ref: &str) -> Result { + let doc = locate_doc(root, doc_ref)?; + std::fs::remove_file(&doc.path)?; + stage_in_git(root, &[&doc.rel_path]).await; + Ok(doc.to_written()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::ErrorCode; + use crate::write::{write_doc_core, DocStatus, NewDoc}; + + fn test_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_del_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn deletes_doc_and_second_delete_is_not_found() { + let root = test_dir("gone"); + let doc = write_doc_core(&root, &NewDoc::new("Doomed", "x", DocStatus::Research)) + .await + .unwrap(); + assert!(doc.path.is_file()); + + let deleted = delete_doc_core(&root, &doc.slug).await.unwrap(); + assert_eq!(deleted.rel_path, "research/doomed.md"); + assert!(!doc.path.exists()); + + let err = delete_doc_core(&root, &doc.slug).await.unwrap_err(); + assert_eq!(err.code, ErrorCode::DocNotFound); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/core/src/error.rs b/src-tauri/core/src/error.rs new file mode 100644 index 0000000..2eb9c10 --- /dev/null +++ b/src-tauri/core/src/error.rs @@ -0,0 +1,82 @@ +use std::fmt; + +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + WorkspaceNotFound, + DocNotFound, + InvalidPath, + InvalidInput, + Conflict, + Io, + Git, +} + +#[derive(Debug, Serialize)] +pub struct CoreError { + pub code: ErrorCode, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery: Option, +} + +impl CoreError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + recovery: None, + } + } + + pub fn with_recovery(mut self, recovery: impl Into) -> Self { + self.recovery = Some(recovery.into()); + self + } +} + +impl fmt::Display for CoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.recovery { + Some(r) => write!(f, "{:?}: {} ({})", self.code, self.message, r), + None => write!(f, "{:?}: {}", self.code, self.message), + } + } +} + +impl std::error::Error for CoreError {} + +impl From for CoreError { + fn from(e: std::io::Error) -> Self { + Self::new(ErrorCode::Io, e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_to_the_wire_shape_agents_parse() { + let err = CoreError::new(ErrorCode::WorkspaceNotFound, "no workspace") + .with_recovery("call list_workspaces"); + let json = serde_json::to_value(&err).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "code": "workspace_not_found", + "message": "no workspace", + "recovery": "call list_workspaces", + }) + ); + } + + #[test] + fn recovery_is_omitted_when_absent() { + let json = serde_json::to_value(CoreError::new(ErrorCode::InvalidPath, "bad")).unwrap(); + assert_eq!(json.as_object().unwrap().len(), 2); + assert_eq!(json["code"], "invalid_path"); + } +} diff --git a/src-tauri/core/src/frontmatter.rs b/src-tauri/core/src/frontmatter.rs new file mode 100644 index 0000000..a5cf19a --- /dev/null +++ b/src-tauri/core/src/frontmatter.rs @@ -0,0 +1,125 @@ +#[derive(Debug, Default)] +pub(crate) struct DocMeta { + pub title: Option, + pub tags: Vec, +} + +pub(crate) fn split_frontmatter(content: &str) -> (Option<&str>, &str) { + let trimmed = content.trim_start_matches('\u{feff}').trim_start(); + let Some(after) = trimmed.strip_prefix("---") else { + return (None, content); + }; + let Some(nl) = after.find('\n') else { + return (None, content); + }; + let rest = &after[nl + 1..]; + let Some(end) = rest.find("\n---") else { + return (None, content); + }; + let fm = &rest[..end]; + let after_fence = &rest[end + 4..]; + let body = match after_fence.find('\n') { + Some(i) => &after_fence[i + 1..], + None => "", + }; + (Some(fm), body) +} + +fn yaml_str(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.into())) + .and_then(|v| v.as_str()) + .map(str::to_string) +} + +pub(crate) fn parse_doc_meta(fm: &str) -> DocMeta { + let Ok(value) = serde_yaml::from_str::(fm) else { + return DocMeta::default(); + }; + let Some(map) = value.as_mapping() else { + return DocMeta::default(); + }; + DocMeta { + title: yaml_str(map, "title"), + tags: map + .get(serde_yaml::Value::String("tags".into())) + .map(parse_tags) + .unwrap_or_default(), + } +} + +/// Replaces the top-level `key:` line inside the frontmatter, or appends it +/// after the existing keys (creating the frontmatter block if the content has +/// none). `line` is a full "key: value" line without a trailing newline; +/// everything else is preserved byte-for-byte. +pub(crate) fn upsert_fm_line(content: &str, key: &str, line: &str) -> String { + let prefix = format!("{key}:"); + let Some(fm) = split_frontmatter(content).0 else { + return format!("---\n{line}\n---\n\n{content}"); + }; + let new_fm = match fm.lines().position(|l| l.starts_with(&prefix)) { + Some(i) => { + let mut lines: Vec<&str> = fm.lines().collect(); + lines[i] = line; + lines.join("\n") + } + None => format!("{}\n{line}", fm.trim_end()), + }; + // fm borrows from content, so its offsets splice the exact byte range. + let start = fm.as_ptr() as usize - content.as_ptr() as usize; + let end = start + fm.len(); + format!("{}{}{}", &content[..start], new_fm, &content[end..]) +} + +/// One "key: value" YAML line with proper escaping, no trailing newline. +pub(crate) fn yaml_line(key: &str, value: &str) -> Result { + let mut map = serde_yaml::Mapping::new(); + map.insert(key.into(), value.into()); + serde_yaml::to_string(&map) + .map(|s| s.trim_end().to_string()) + .map_err(|e| { + crate::error::CoreError::new( + crate::error::ErrorCode::Io, + format!("serialize {key}: {e}"), + ) + }) +} + +pub(crate) fn parse_tags(value: &serde_yaml::Value) -> Vec { + if let Some(seq) = value.as_sequence() { + return seq + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + } + if let Some(s) = value.as_str() { + return s + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + } + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_frontmatter_and_body() { + let (fm, body) = split_frontmatter("---\ntitle: X\ntags: [a]\n---\n\nBody here."); + assert_eq!(fm, Some("title: X\ntags: [a]")); + assert_eq!(body, "\nBody here."); + + let meta = parse_doc_meta(fm.unwrap()); + assert_eq!(meta.title.as_deref(), Some("X")); + assert_eq!(meta.tags, vec!["a".to_string()]); + } + + #[test] + fn no_frontmatter_returns_full_body() { + let (fm, body) = split_frontmatter("# Just a heading\n"); + assert_eq!(fm, None); + assert_eq!(body, "# Just a heading\n"); + } +} diff --git a/src-tauri/core/src/git.rs b/src-tauri/core/src/git.rs new file mode 100644 index 0000000..2c0882e --- /dev/null +++ b/src-tauri/core/src/git.rs @@ -0,0 +1,325 @@ +// Shells out to `git` via tokio with a per-call timeout, rather than linking +// libgit2: zero bundle weight and the user's own git always understands their repo. + +use std::process::Stdio; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncReadExt; + +const GIT_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_GIT_STDOUT_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Serialize, Deserialize)] +pub struct GitFileStatus { + pub path: String, + pub status: String, + #[serde( + default, + rename = "originalPath", + skip_serializing_if = "Option::is_none" + )] + pub original_path: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GitStatus { + pub root: String, + pub files: Vec, +} + +pub struct GitOutput { + pub success: bool, + pub stdout: Vec, + pub stderr: String, +} + +// PATH on a GUI-launched macOS app lacks Homebrew dirs, so probe common +// install locations as a fallback. Cached after the first lookup. +pub fn git_binary() -> Option<&'static str> { + static CACHED: std::sync::OnceLock> = std::sync::OnceLock::new(); + *CACHED.get_or_init(|| { + const CANDIDATES: &[&str] = &[ + "git", + "/usr/bin/git", + "/opt/homebrew/bin/git", + "/usr/local/bin/git", + ]; + for c in CANDIDATES { + let ok = std::process::Command::new(c) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if ok { + return Some(*c); + } + } + None + }) +} + +// stdout is capped at MAX_GIT_STDOUT_BYTES: a huge committed blob (git show) or +// a repo with enormous status output must not be buffered unbounded into memory. +// Overflow is drained to a sink so the child can't block on a full pipe. +pub async fn run_git(args: &[&str]) -> Result { + let bin = match git_binary() { + Some(b) => b, + None => return Err("git not found".to_string()), + }; + let label = args.first().copied().unwrap_or(""); + let collect = async { + let mut child = tokio::process::Command::new(bin) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("git {label}: {e}"))?; + let mut stdout_pipe = child.stdout.take().ok_or("git stdout unavailable")?; + let mut stderr_pipe = child.stderr.take().ok_or("git stderr unavailable")?; + + let read_stdout = async { + let mut buf = Vec::new(); + let cap = MAX_GIT_STDOUT_BYTES as u64 + 1; + (&mut stdout_pipe).take(cap).read_to_end(&mut buf).await?; + tokio::io::copy(&mut stdout_pipe, &mut tokio::io::sink()).await?; + buf.truncate(MAX_GIT_STDOUT_BYTES); + Ok::, std::io::Error>(buf) + }; + let read_stderr = async { + let mut s = String::new(); + let _ = stderr_pipe.read_to_string(&mut s).await; + s + }; + let (stdout_res, stderr) = tokio::join!(read_stdout, read_stderr); + let stdout = stdout_res.map_err(|e| format!("git {label}: {e}"))?; + let status = child + .wait() + .await + .map_err(|e| format!("git {label}: {e}"))?; + Ok::(GitOutput { + success: status.success(), + stdout, + stderr, + }) + }; + match tokio::time::timeout(GIT_TIMEOUT, collect).await { + Ok(r) => r, + Err(_) => Err(format!("git {label} timed out")), + } +} + +pub async fn is_git_repo(dir: &str) -> bool { + match run_git(&["-C", dir, "rev-parse", "--is-inside-work-tree"]).await { + Ok(o) => o.success && String::from_utf8_lossy(&o.stdout).trim() == "true", + Err(_) => false, + } +} + +pub async fn git_add(dir: &str, paths: &[&str]) -> bool { + let mut args = vec!["-C", dir, "add", "-A", "--"]; + args.extend_from_slice(paths); + matches!(run_git(&args).await, Ok(o) if o.success) +} + +pub async fn git_mv(dir: &str, from: &str, to: &str) -> bool { + matches!( + run_git(&["-C", dir, "mv", "--", from, to]).await, + Ok(o) if o.success + ) +} + +fn classify_xy(xy: &str) -> &'static str { + let bytes = xy.as_bytes(); + if bytes.len() < 2 { + return "modified"; + } + let x = bytes[0] as char; + let y = bytes[1] as char; + if x == '?' || y == '?' { + return "untracked"; + } + if x == 'U' || y == 'U' || (x == 'D' && y == 'D') || (x == 'A' && y == 'A') { + return "unmerged"; + } + if x == 'A' || y == 'A' { + return "added"; + } + if x == 'D' || y == 'D' { + return "deleted"; + } + if x == 'R' || y == 'R' || x == 'C' || y == 'C' { + return "renamed"; + } + "modified" +} + +pub async fn git_status_core(workspace: String) -> Result, String> { + if git_binary().is_none() { + return Ok(None); + } + let toplevel_out = match run_git(&["-C", &workspace, "rev-parse", "--show-toplevel"]).await { + Ok(o) => o, + Err(_) => return Ok(None), + }; + if !toplevel_out.success { + return Ok(None); + } + let toplevel = String::from_utf8_lossy(&toplevel_out.stdout) + .trim() + .to_string(); + + // Workspace must live inside the repo. Compute the prefix so we can + // translate repo-relative paths (what git emits) into + // workspace-relative paths (what the scan uses). + let ws_canonical = std::path::Path::new(&workspace) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(&workspace).to_path_buf()); + let tl_canonical = std::path::Path::new(&toplevel) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(&toplevel).to_path_buf()); + let prefix = ws_canonical + .strip_prefix(&tl_canonical) + .ok() + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + + let status_out = run_git(&["-C", &workspace, "status", "--porcelain=v1", "-z"]).await?; + if !status_out.success { + return Err(format!("git status: {}", status_out.stderr)); + } + + let mut files = Vec::new(); + let mut iter = status_out + .stdout + .split(|b| *b == 0) + .filter(|t| !t.is_empty()) + .peekable(); + while let Some(tok) = iter.next() { + let s = match std::str::from_utf8(tok) { + Ok(s) => s, + Err(_) => continue, + }; + if s.len() < 4 { + continue; + } + let xy = &s[..2]; + let path = s[3..].to_string(); + let status = classify_xy(xy); + let is_rename = xy.contains('R') || xy.contains('C'); + + let original_path = if is_rename { + iter.next() + .and_then(|t| std::str::from_utf8(t).ok().map(|s| s.to_string())) + } else { + None + }; + + let final_path = if prefix.is_empty() { + path.clone() + } else if path == prefix { + String::new() + } else if let Some(rest) = path.strip_prefix(&format!("{}/", prefix)) { + rest.to_string() + } else { + continue; + }; + + files.push(GitFileStatus { + path: final_path, + status: status.to_string(), + original_path, + }); + } + + Ok(Some(GitStatus { + root: toplevel, + files, + })) +} + +pub async fn git_show_head_core(workspace: String, path: String) -> Result, String> { + if git_binary().is_none() { + return Ok(None); + } + let out = run_git(&["-C", &workspace, "show", &format!("HEAD:./{}", path), "--"]).await?; + if !out.success { + // Untracked / new file has no HEAD revision: not an error - the caller + // renders an "all added" diff. + if out.stderr.contains("exists on disk, but not in") + || out.stderr.contains("does not exist") + || out.stderr.contains("path does not exist") + || out.stderr.contains("bad revision") + { + return Ok(None); + } + return Err(format!("git show: {}", out.stderr)); + } + Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + use std::process::Command; + + fn git(dir: &Path, args: &[&str]) { + let ok = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .map(|s| s.success()) + .unwrap_or(false); + assert!(ok, "git {args:?} failed"); + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_git_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn show_head_returns_content_and_none_for_untracked() { + if git_binary().is_none() { + return; + } + let dir = temp_dir("show"); + git(&dir, &["init", "-q"]); + git(&dir, &["config", "user.email", "a@b.c"]); + git(&dir, &["config", "user.name", "x"]); + std::fs::write(dir.join("f.md"), "hello\n").unwrap(); + git(&dir, &["add", "f.md"]); + git(&dir, &["commit", "-qm", "init"]); + + let ws = dir.to_string_lossy().to_string(); + let committed = git_show_head_core(ws.clone(), "f.md".into()).await.unwrap(); + assert_eq!(committed.as_deref(), Some("hello\n")); + + std::fs::write(dir.join("new.md"), "x\n").unwrap(); + let untracked = git_show_head_core(ws.clone(), "new.md".into()) + .await + .unwrap(); + assert_eq!(untracked, None); + + let status = git_status_core(ws).await.unwrap(); + assert!(status.is_some(), "files() in a repo returns Some"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn status_on_non_repo_is_none() { + if git_binary().is_none() { + return; + } + let dir = temp_dir("nonrepo"); + let status = git_status_core(dir.to_string_lossy().to_string()) + .await + .unwrap(); + assert!(status.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs new file mode 100644 index 0000000..6952e29 --- /dev/null +++ b/src-tauri/core/src/lib.rs @@ -0,0 +1,15 @@ +pub mod delete; +pub mod error; +mod frontmatter; +pub mod git; +pub mod links; +pub mod memory; +pub mod path_guard; +pub mod read; +pub mod rename; +pub mod scan; +pub mod slug; +pub mod tasks; +pub mod update; +pub mod workspace; +pub mod write; diff --git a/src-tauri/core/src/links.rs b/src-tauri/core/src/links.rs new file mode 100644 index 0000000..8d7dab5 --- /dev/null +++ b/src-tauri/core/src/links.rs @@ -0,0 +1,127 @@ +use std::path::{Component, Path, PathBuf}; + +use crate::scan::is_markdown; + +/// Workspace-relative markdown targets of the inline `[text](target)` links +/// in `content`, resolved against the linking file's own directory. +/// External URLs, absolute paths, and links escaping the workspace drop out. +pub fn links_from(content: &str, source_rel: &str) -> Vec { + let mut out: Vec = Vec::new(); + for target in inline_targets(content) { + if let Some(resolved) = resolve_link(source_rel, target) { + if !out.contains(&resolved) { + out.push(resolved); + } + } + } + out +} + +fn inline_targets(content: &str) -> impl Iterator { + content.split("](").skip(1).filter_map(|rest| { + if let Some(stripped) = rest.strip_prefix('<') { + stripped.split('>').next() + } else { + rest.split(')') + .next() + .and_then(|t| t.split_whitespace().next()) + } + }) +} + +fn resolve_link(source_rel: &str, target: &str) -> Option { + let decoded = percent_decode(target.split('#').next().unwrap_or("")); + if decoded.is_empty() || decoded.starts_with('/') { + return None; + } + // A colon in the first segment means a scheme (https:, mailto:) or a + // Windows drive - either way not a workspace-relative link. + if decoded.split('/').next().is_some_and(|s| s.contains(':')) { + return None; + } + if !is_markdown(&decoded) { + return None; + } + + let source_dir = Path::new(source_rel).parent().unwrap_or(Path::new("")); + let mut stack: Vec<&std::ffi::OsStr> = Vec::new(); + for comp in source_dir + .components() + .chain(Path::new(&decoded).components()) + { + match comp { + Component::Normal(c) => stack.push(c), + Component::ParentDir => { + stack.pop()?; + } + Component::CurDir => {} + Component::RootDir | Component::Prefix(_) => return None, + } + } + Some( + stack + .iter() + .collect::() + .to_string_lossy() + .into_owned(), + ) +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let decoded = (bytes[i] == b'%' && i + 2 < bytes.len()) + .then(|| u8::from_str_radix(&s[i + 1..i + 3], 16).ok()) + .flatten(); + match decoded { + Some(b) => { + out.push(b); + i += 3; + } + None => { + out.push(bytes[i]); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_and_resolves_relative_links() { + let content = "See [a](./sibling.md), [b](../up/other.md), and [c](nested/deep.md)."; + assert_eq!( + links_from(content, "docs/source.md"), + ["docs/sibling.md", "up/other.md", "docs/nested/deep.md"] + ); + } + + #[test] + fn skips_external_absolute_anchor_and_non_markdown_targets() { + let content = "[u](https://x.com/a.md) [m](mailto:a@b.md) [abs](/etc/a.md) \ + [anchor](#section) [img](./pic.png) [code](./main.rs)"; + assert_eq!(links_from(content, "doc.md"), Vec::::new()); + } + + #[test] + fn strips_fragments_decodes_percent_and_handles_angle_form() { + let content = "[a](./target.md#heading) [b](./my%20doc.md) [c](<./my doc.md>) \ + [titled](./cited.md \"Title\")"; + assert_eq!( + links_from(content, "doc.md"), + ["target.md", "my doc.md", "cited.md"] + ); + } + + #[test] + fn drops_links_escaping_the_workspace_and_dedupes() { + let content = "[esc](../../outside.md) [a](./a.md) [again](a.md)"; + assert_eq!(links_from(content, "sub/doc.md"), ["sub/a.md"]); + } +} diff --git a/src-tauri/core/src/memory.rs b/src-tauri/core/src/memory.rs new file mode 100644 index 0000000..4964b5b --- /dev/null +++ b/src-tauri/core/src/memory.rs @@ -0,0 +1,326 @@ +use std::path::{Path, PathBuf}; + +use chrono::{SecondsFormat, Utc}; +use serde::Serialize; + +use crate::error::{CoreError, ErrorCode}; +use crate::frontmatter::{parse_doc_meta, split_frontmatter}; +use crate::read::score_match; +use crate::slug::slugify; +use crate::write::{enforce_size_limit, stage_in_git}; + +pub const MEMORY_DIR: &str = "memory"; + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct MemoryEntry { + pub slug: String, + pub rel_path: String, + pub path: PathBuf, + /// false when an existing entry for the topic was overwritten. + pub created: bool, +} + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct MemoryHit { + pub slug: String, + pub rel_path: String, + pub title: Option, + pub tags: Vec, + /// Full body of the entry; memory entries are short by design. + pub content: String, + pub score: u32, + pub modified: Option, +} + +pub fn memory_rel_path(slug: &str) -> String { + format!("{MEMORY_DIR}/{slug}.md") +} + +fn memory_slug(mem_ref: &str) -> Result { + let slug = mem_ref + .strip_prefix("memory/") + .unwrap_or(mem_ref) + .trim_end_matches(".md"); + if slug.is_empty() || slugify(slug) != slug { + return Err(CoreError::new( + ErrorCode::InvalidInput, + format!("invalid memory reference {mem_ref:?}"), + ) + .with_recovery("pass a topic slug or a path like \"memory/user-preferences.md\"")); + } + Ok(slug.to_string()) +} + +#[derive(Serialize)] +struct MemoryFrontmatter<'a> { + title: &'a str, + #[serde(skip_serializing_if = "<[String]>::is_empty")] + tags: &'a [String], + created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + created_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + updated_by: Option<&'a str>, +} + +fn existing_creation_stamp(path: &Path) -> Option<(String, Option)> { + let content = std::fs::read_to_string(path).ok()?; + let fm = split_frontmatter(&content).0?; + let map: serde_yaml::Mapping = serde_yaml::from_str(fm).ok()?; + let field = |key: &str| { + map.get(serde_yaml::Value::String(key.into())) + .and_then(|v| v.as_str()) + .map(str::to_string) + }; + Some((field("created_at")?, field("created_by"))) +} + +/// Upsert by topic: one entry per topic slug, overwritten wholesale on each +/// write (the Anthropic memory-tool "create or overwrite" contract). +/// created_at/created_by survive updates; updated_at/updated_by track them. +pub async fn write_memory_core( + root: &Path, + topic: &str, + content: &str, + tags: &[String], + agent: Option<&str>, +) -> Result { + if topic.trim().is_empty() { + return Err(CoreError::new(ErrorCode::InvalidInput, "topic is required") + .with_recovery("pass a short topic, e.g. \"user-preferences\"")); + } + let slug = slugify(topic); + let rel_path = memory_rel_path(&slug); + let path = root.join(&rel_path); + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let existing = existing_creation_stamp(&path); + let created = existing.is_none(); + let (created_at, created_by) = + existing.unwrap_or_else(|| (now.clone(), agent.map(str::to_string))); + let fm = serde_yaml::to_string(&MemoryFrontmatter { + title: topic, + tags, + created_at, + created_by, + updated_at: (!created).then(|| now.clone()), + updated_by: (!created).then_some(agent).flatten(), + }) + .map_err(|e| CoreError::new(ErrorCode::Io, format!("serialize frontmatter: {e}")))?; + let rendered = format!("---\n{fm}---\n\n{}\n", content.trim_end()); + enforce_size_limit(rendered.len())?; + std::fs::create_dir_all(path.parent().unwrap_or(root))?; + std::fs::write(&path, rendered)?; + stage_in_git(root, &[&rel_path]).await; + Ok(MemoryEntry { + slug, + rel_path, + path, + created, + }) +} + +/// Empty/absent query lists everything, newest first; otherwise hits are +/// ranked like search_docs. Full content rides along so recall is one call. +pub fn search_memory_core( + root: &Path, + query: Option<&str>, + tag: Option<&str>, +) -> Result, CoreError> { + if !root.is_dir() { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + format!("workspace directory {} is missing", root.display()), + ) + .with_recovery("call list_workspaces to see valid slugs")); + } + let q = query + .map(|q| q.trim().to_lowercase()) + .filter(|q| !q.is_empty()); + let mut hits = Vec::new(); + for entry in std::fs::read_dir(root.join(MEMORY_DIR)) + .into_iter() + .flatten() + .flatten() + { + let Some(hit) = memory_hit(&entry.path()) else { + continue; + }; + if tag.is_some_and(|t| !hit.tags.iter().any(|x| x == t)) { + continue; + } + match &q { + None => hits.push(hit), + Some(q) => { + let score = score_match( + hit.title.as_deref(), + &hit.tags, + &hit.slug, + hit.content.to_lowercase().contains(q), + q, + ); + if score > 0 { + hits.push(MemoryHit { score, ..hit }); + } + } + } + } + match q { + None => hits.sort_by(|a, b| { + b.modified + .cmp(&a.modified) + .then_with(|| a.slug.cmp(&b.slug)) + }), + Some(_) => hits.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.slug.cmp(&b.slug))), + } + Ok(hits) +} + +fn memory_hit(path: &Path) -> Option { + if path.extension().is_none_or(|e| e != "md") || !path.is_file() { + return None; + } + let slug = path.file_stem()?.to_string_lossy().to_string(); + let content = std::fs::read_to_string(path).ok()?; + let (fm, body) = split_frontmatter(&content); + let meta = fm.map(parse_doc_meta).unwrap_or_default(); + let modified = std::fs::metadata(path) + .ok()? + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + Some(MemoryHit { + rel_path: memory_rel_path(&slug), + slug, + title: meta.title, + tags: meta.tags, + content: body.trim().to_string(), + score: 0, + modified, + }) +} + +pub async fn delete_memory_core(root: &Path, mem_ref: &str) -> Result { + let slug = memory_slug(mem_ref)?; + let rel_path = memory_rel_path(&slug); + let path = root.join(&rel_path); + if !path.is_file() { + return Err(CoreError::new( + ErrorCode::DocNotFound, + format!("no memory entry for {slug:?}"), + ) + .with_recovery("call search_memory to see existing entries")); + } + std::fs::remove_file(&path)?; + stage_in_git(root, &[&rel_path]).await; + Ok(MemoryEntry { + slug, + rel_path, + path, + created: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dr_mem_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn upsert_creates_then_overwrites_preserving_creation_stamp() { + let root = test_dir("upsert"); + let first = write_memory_core(&root, "User Preferences", "Likes tabs.", &[], Some("codex")) + .await + .unwrap(); + assert!(first.created); + assert_eq!(first.rel_path, "memory/user-preferences.md"); + let raw = std::fs::read_to_string(&first.path).unwrap(); + assert!(raw.contains("created_by: codex")); + assert!(!raw.contains("updated_at:")); + + let second = write_memory_core( + &root, + "User Preferences", + "Likes spaces now.", + &[], + Some("claude-code"), + ) + .await + .unwrap(); + assert!(!second.created, "same topic upserts"); + let raw = std::fs::read_to_string(&second.path).unwrap(); + assert!(raw.contains("created_by: codex"), "creation stamp survives"); + assert!(raw.contains("updated_by: claude-code")); + assert!(raw.contains("updated_at:")); + assert!(raw.ends_with("Likes spaces now.\n")); + assert!(!raw.contains("tabs")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn search_ranks_and_empty_query_lists_all() { + let root = test_dir("search"); + write_memory_core( + &root, + "Auth Stack", + "Uses Better Auth.", + &["stack".into()], + None, + ) + .await + .unwrap(); + write_memory_core(&root, "Editor", "Neovim, auth for git via ssh.", &[], None) + .await + .unwrap(); + + let all = search_memory_core(&root, None, None).unwrap(); + assert_eq!(all.len(), 2); + assert!(all.iter().all(|h| !h.content.is_empty())); + + let hits = search_memory_core(&root, Some("auth"), None).unwrap(); + assert_eq!(hits.len(), 2); + assert_eq!( + hits[0].slug, "auth-stack", + "title+slug match outranks content" + ); + assert!(hits[0].score > hits[1].score); + + let tagged = search_memory_core(&root, None, Some("stack")).unwrap(); + assert_eq!(tagged.len(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn delete_removes_entry_and_rejects_bad_refs() { + let root = test_dir("del"); + write_memory_core(&root, "Stale Fact", "old", &[], None) + .await + .unwrap(); + + let gone = delete_memory_core(&root, "memory/stale-fact.md") + .await + .unwrap(); + assert!(!gone.path.exists()); + + let err = delete_memory_core(&root, "stale-fact").await.unwrap_err(); + assert_eq!(err.code, ErrorCode::DocNotFound); + + let err = delete_memory_core(&root, "memory/../escape") + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidInput); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/core/src/path_guard.rs b/src-tauri/core/src/path_guard.rs new file mode 100644 index 0000000..44ca4d7 --- /dev/null +++ b/src-tauri/core/src/path_guard.rs @@ -0,0 +1,81 @@ +use std::path::{Component, Path, PathBuf}; + +use crate::error::{CoreError, ErrorCode}; + +const ENCODED_TRAVERSAL_MARKERS: &[&str] = &["%2e", "%2f", "%5c"]; + +fn invalid(relative: &str, why: &str) -> CoreError { + CoreError::new( + ErrorCode::InvalidPath, + format!("invalid path {relative:?}: {why}"), + ) + .with_recovery("use a relative path inside the workspace, e.g. \"guides/setup.md\"") +} + +pub fn safe_join(root: &Path, relative: &str) -> Result { + if relative.is_empty() { + return Err(invalid(relative, "empty path")); + } + let lower = relative.to_ascii_lowercase(); + if ENCODED_TRAVERSAL_MARKERS.iter().any(|m| lower.contains(m)) { + return Err(invalid(relative, "percent-encoded separator or dot")); + } + if relative.contains(':') { + return Err(invalid(relative, "drive or stream separator")); + } + if relative.contains('\0') { + return Err(invalid(relative, "NUL byte")); + } + let normalized = relative.replace('\\', "/"); + let rel_path = Path::new(&normalized); + for component in rel_path.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + Component::ParentDir => return Err(invalid(relative, "parent traversal")), + Component::RootDir | Component::Prefix(_) => { + return Err(invalid(relative, "absolute path")) + } + } + } + Ok(root.join(rel_path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn join(rel: &str) -> Result { + safe_join(Path::new("/ws"), rel) + } + + #[test] + fn accepts_normal_relative_paths() { + assert_eq!( + join("guides/setup.md").unwrap(), + Path::new("/ws/guides/setup.md") + ); + assert_eq!(join("./a.md").unwrap(), Path::new("/ws/a.md")); + assert_eq!(join("目录/文件.md").unwrap(), Path::new("/ws/目录/文件.md")); + } + + #[test] + fn rejects_traversal_vectors() { + for vector in [ + "", + "../evil.md", + "a/../../evil.md", + "/etc/passwd", + "C:\\evil.md", + "c:/evil.md", + "a\\..\\evil.md", + "%2e%2e%2fevil.md", + "%2E%2E/evil.md", + "a%2fevil.md", + "a%5cevil.md", + "a\0.md", + ] { + let err = join(vector).unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidPath, "vector: {vector:?}"); + } + } +} diff --git a/src-tauri/core/src/read.rs b/src-tauri/core/src/read.rs new file mode 100644 index 0000000..3357f3c --- /dev/null +++ b/src-tauri/core/src/read.rs @@ -0,0 +1,400 @@ +use std::path::Path; + +use serde::Serialize; + +use crate::error::{CoreError, ErrorCode}; +use crate::frontmatter::{parse_doc_meta, split_frontmatter}; +use crate::write::DocStatus; + +/// ~25k tokens at ~4 chars/token; MCP responses stay under this. +pub const RESPONSE_BUDGET_CHARS: usize = 100_000; +pub const SNIPPET_CHARS: usize = 500; + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct DocSummary { + pub slug: String, + pub rel_path: String, + pub status: DocStatus, + pub title: Option, + pub tags: Vec, + pub phase: Option, + pub size: u64, + pub modified: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct SearchHit { + #[serde(flatten)] + pub doc: DocSummary, + pub score: u32, + pub snippet: Option, +} + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct DocContent { + pub slug: String, + pub rel_path: String, + pub status: DocStatus, + pub phase: Option, + pub frontmatter: Option, + pub title: Option, + /// Full markdown body (detailed mode only). + pub body: Option, + /// Leading excerpt of the body (concise mode only). + pub snippet: Option, + pub size: u64, + pub truncated: bool, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct DocFilters<'a> { + pub status: Option, + pub phase: Option<&'a str>, + pub tag: Option<&'a str>, +} + +fn truncate_at_char_boundary(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} + +fn doc_summary( + root: &Path, + status: DocStatus, + phase: Option<&str>, + path: &Path, +) -> Option<(DocSummary, String)> { + let slug = path.file_stem()?.to_string_lossy().to_string(); + let metadata = std::fs::metadata(path).ok()?; + let content = std::fs::read_to_string(path).ok()?; + let (fm, _) = split_frontmatter(&content); + let meta = fm.map(parse_doc_meta).unwrap_or_default(); + let rel_path = path + .strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + let modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + Some(( + DocSummary { + slug, + rel_path, + status, + title: meta.title, + tags: meta.tags, + phase: phase.map(str::to_string), + size: metadata.len(), + modified, + }, + content, + )) +} + +fn matches_filters(doc: &DocSummary, filters: &DocFilters<'_>) -> bool { + if let Some(status) = filters.status { + if doc.status != status { + return false; + } + } + if let Some(phase) = filters.phase { + if doc.phase.as_deref() != Some(phase) { + return false; + } + } + if let Some(tag) = filters.tag { + if !doc.tags.iter().any(|t| t == tag) { + return false; + } + } + true +} + +fn collect_docs( + root: &Path, + filters: &DocFilters<'_>, +) -> Result, CoreError> { + if !root.is_dir() { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + format!("workspace directory {} is missing", root.display()), + ) + .with_recovery("call list_workspaces to see valid slugs")); + } + let mut docs = Vec::new(); + let mut push_doc = |status: DocStatus, phase: Option<&str>, path: &Path| { + let is_md = path.extension().is_some_and(|e| e == "md"); + if !is_md || !path.is_file() { + return; + } + if let Some((doc, content)) = doc_summary(root, status, phase, path) { + if matches_filters(&doc, filters) { + docs.push((doc, content)); + } + } + }; + for status in DocStatus::ALL { + let entries = match std::fs::read_dir(root.join(status.folder())) { + Ok(entries) => entries, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let phase = path.file_name().map(|n| n.to_string_lossy().to_string()); + for nested in std::fs::read_dir(&path).into_iter().flatten().flatten() { + push_doc(status, phase.as_deref(), &nested.path()); + } + } else { + push_doc(status, None, &path); + } + } + } + docs.sort_by(|a, b| { + b.0.modified + .cmp(&a.0.modified) + .then_with(|| a.0.rel_path.cmp(&b.0.rel_path)) + }); + Ok(docs) +} + +pub fn list_docs_core(root: &Path, filters: &DocFilters<'_>) -> Result, CoreError> { + Ok(collect_docs(root, filters)? + .into_iter() + .map(|(doc, _)| doc) + .collect()) +} + +const SCORE_TITLE: u32 = 3; +const SCORE_TAG: u32 = 2; +const SCORE_SLUG: u32 = 2; +const SCORE_CONTENT: u32 = 1; + +pub(crate) fn score_match( + title: Option<&str>, + tags: &[String], + slug: &str, + body_matches: bool, + query_lower: &str, +) -> u32 { + let mut score = 0u32; + if title.is_some_and(|t| t.to_lowercase().contains(query_lower)) { + score += SCORE_TITLE; + } + if tags.iter().any(|t| t.to_lowercase() == query_lower) { + score += SCORE_TAG; + } + if slug.to_lowercase().contains(query_lower) { + score += SCORE_SLUG; + } + if body_matches { + score += SCORE_CONTENT; + } + score +} + +fn content_snippet(content: &str, query_lower: &str) -> Option { + let body = split_frontmatter(content).1; + let lower = body.to_lowercase(); + let hit = lower.find(query_lower)?; + let start = body[..hit] + .char_indices() + .rev() + .take(80) + .last() + .map(|(i, _)| i) + .unwrap_or(hit); + let excerpt = truncate_at_char_boundary(&body[start..], 160); + Some(format!("...{}...", excerpt.trim())) +} + +pub fn search_docs_core( + root: &Path, + query: &str, + filters: &DocFilters<'_>, +) -> Result, CoreError> { + if query.trim().is_empty() { + return Err( + CoreError::new(ErrorCode::InvalidInput, "query must not be empty") + .with_recovery("pass a search term, or use list_docs to browse"), + ); + } + let q = query.to_lowercase(); + let mut hits = Vec::new(); + for (doc, content) in collect_docs(root, filters)? { + let snippet = content_snippet(&content, &q); + let score = score_match( + doc.title.as_deref(), + &doc.tags, + &doc.slug, + snippet.is_some(), + &q, + ); + if score > 0 { + hits.push(SearchHit { + doc, + score, + snippet, + }); + } + } + hits.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.doc.rel_path.cmp(&b.doc.rel_path)) + }); + Ok(hits) +} + +pub fn read_doc_core(root: &Path, doc_ref: &str, detailed: bool) -> Result { + let doc = crate::write::locate_doc(root, doc_ref)?; + let content = std::fs::read_to_string(&doc.path)?; + let size = content.len() as u64; + let (fm, body) = split_frontmatter(&content); + let meta = fm.map(parse_doc_meta).unwrap_or_default(); + let frontmatter = fm + .and_then(|raw| serde_yaml::from_str::(raw).ok()) + .and_then(|v| serde_json::to_value(v).ok()); + + let body = body.trim_start_matches('\n'); + let (body_out, snippet, truncated) = if detailed { + let capped = truncate_at_char_boundary(body, RESPONSE_BUDGET_CHARS); + (Some(capped.to_string()), None, capped.len() < body.len()) + } else { + let snip = truncate_at_char_boundary(body, SNIPPET_CHARS); + (None, Some(snip.to_string()), snip.len() < body.len()) + }; + + Ok(DocContent { + slug: doc.slug, + rel_path: doc.rel_path, + status: doc.status, + phase: doc.phase, + frontmatter, + title: meta.title, + body: body_out, + snippet, + size, + truncated, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::write::{write_doc_core, NewDoc}; + + fn test_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_read_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + async fn seed(root: &Path) { + let mut alpha = NewDoc::new("Alpha Guide", "How to use alpha features.", DocStatus::Done); + alpha.tags = vec!["guide".into()]; + alpha.phase = Some("v1"); + write_doc_core(root, &alpha).await.unwrap(); + + let mut beta = NewDoc::new( + "Beta Notes", + "Rough notes mentioning alpha once.", + DocStatus::Research, + ); + beta.tags = vec!["notes".into()]; + write_doc_core(root, &beta).await.unwrap(); + } + + #[tokio::test] + async fn list_filters_and_together() { + let root = test_dir("list"); + seed(&root).await; + + let all = list_docs_core(&root, &DocFilters::default()).unwrap(); + assert_eq!(all.len(), 2); + + let filtered = list_docs_core( + &root, + &DocFilters { + status: Some(DocStatus::Done), + tag: Some("guide"), + phase: Some("v1"), + }, + ) + .unwrap(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].slug, "alpha-guide"); + + let none = list_docs_core( + &root, + &DocFilters { + status: Some(DocStatus::Done), + tag: Some("notes"), + phase: None, + }, + ) + .unwrap(); + assert!(none.is_empty(), "filters AND together"); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn search_ranks_title_match_above_content_match() { + let root = test_dir("search"); + seed(&root).await; + + let hits = search_docs_core(&root, "alpha", &DocFilters::default()).unwrap(); + assert_eq!(hits.len(), 2); + assert_eq!(hits[0].doc.slug, "alpha-guide", "title match first"); + assert!(hits[0].score > hits[1].score); + assert!(hits[1].snippet.as_deref().unwrap().contains("alpha")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn read_concise_vs_detailed() { + let root = test_dir("read"); + seed(&root).await; + + let concise = read_doc_core(&root, "alpha-guide", false).unwrap(); + assert!(concise.body.is_none()); + assert!(concise.snippet.as_deref().unwrap().contains("alpha")); + assert_eq!(concise.status, DocStatus::Done); + assert_eq!(concise.phase.as_deref(), Some("v1"), "phase from subfolder"); + assert!(concise.frontmatter.is_some()); + + let detailed = read_doc_core(&root, "done/v1/alpha-guide.md", true).unwrap(); + assert!(detailed.body.as_deref().unwrap().contains("alpha features")); + assert!(detailed.snippet.is_none()); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn read_missing_doc_is_not_found_and_traversal_rejected() { + let root = test_dir("read_missing"); + seed(&root).await; + + let err = read_doc_core(&root, "ghost", false).unwrap_err(); + assert_eq!(err.code, ErrorCode::DocNotFound); + + let err = read_doc_core(&root, "../outside.md", false).unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidPath); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/core/src/rename.rs b/src-tauri/core/src/rename.rs new file mode 100644 index 0000000..2efbcfe --- /dev/null +++ b/src-tauri/core/src/rename.rs @@ -0,0 +1,137 @@ +use std::path::Path; + +use crate::error::{CoreError, ErrorCode}; +use crate::frontmatter::{upsert_fm_line, yaml_line}; +use crate::slug::slugify; +use crate::write::{ + find_doc_location, locate_doc, location_at, relocate, stage_in_git, WrittenDoc, +}; + +/// The new title becomes both the frontmatter title and the slug/filename; +/// status and phase stay put. A slug already taken by another doc is a +/// Conflict, never a silent counter suffix. +pub async fn rename_doc_core( + root: &Path, + doc_ref: &str, + new_title: &str, +) -> Result { + if new_title.trim().is_empty() { + return Err( + CoreError::new(ErrorCode::InvalidInput, "new_title is required") + .with_recovery("pass a short human-readable title; it becomes the doc's new slug"), + ); + } + let from = locate_doc(root, doc_ref)?; + let new_slug = slugify(new_title); + let target = if new_slug == from.slug { + from + } else { + if let Some(existing) = find_doc_location(root, &new_slug) { + return Err(CoreError::new( + ErrorCode::Conflict, + format!( + "a doc with slug {new_slug:?} already exists at {:?}", + existing.rel_path + ), + ) + .with_recovery("choose a different title, or archive/delete the existing doc first")); + } + let to = location_at(root, from.status, from.phase.as_deref(), &new_slug); + relocate(root, &from, &to).await?; + to + }; + let content = std::fs::read_to_string(&target.path)?; + std::fs::write(&target.path, with_title(&content, new_title)?)?; + stage_in_git(root, &[&target.rel_path]).await; + Ok(target.to_written()) +} + +fn with_title(content: &str, title: &str) -> Result { + let line = yaml_line("title", title)?; + Ok(upsert_fm_line(content, "title", &line)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::write::{set_status_core, write_doc_core, DocStatus, NewDoc}; + + fn test_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_ren_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn rename_moves_file_and_updates_title_within_status_and_phase() { + let root = test_dir("move"); + let doc = write_doc_core( + &root, + &NewDoc { + phase: Some("v1"), + tags: vec!["keep".into()], + ..NewDoc::new("Old Name", "Body.", DocStatus::Research) + }, + ) + .await + .unwrap(); + + let renamed = rename_doc_core(&root, &doc.slug, "New Name").await.unwrap(); + assert_eq!(renamed.rel_path, "research/v1/new-name.md"); + assert!(!doc.path.exists()); + + let raw = std::fs::read_to_string(&renamed.path).unwrap(); + assert!(raw.contains("title: New Name"), "got: {raw}"); + assert!(!raw.contains("Old Name")); + assert!(raw.contains("- keep"), "other frontmatter preserved: {raw}"); + assert!(raw.ends_with("Body.\n")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn same_slug_rename_only_updates_title() { + let root = test_dir("retitle"); + let doc = write_doc_core(&root, &NewDoc::new("my doc", "x", DocStatus::Done)) + .await + .unwrap(); + + let renamed = rename_doc_core(&root, &doc.slug, "My Doc").await.unwrap(); + assert_eq!(renamed.rel_path, doc.rel_path); + let raw = std::fs::read_to_string(&renamed.path).unwrap(); + assert!(raw.contains("title: My Doc")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn rename_onto_existing_slug_is_conflict() { + let root = test_dir("clash"); + write_doc_core(&root, &NewDoc::new("Taken", "a", DocStatus::Research)) + .await + .unwrap(); + let doc = write_doc_core(&root, &NewDoc::new("Mine", "b", DocStatus::Done)) + .await + .unwrap(); + set_status_core(&root, "taken", DocStatus::Archived) + .await + .unwrap(); + + let err = rename_doc_core(&root, &doc.slug, "Taken") + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict); + assert!(err.message.contains("archived/taken.md"), "{}", err.message); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn with_title_escapes_yaml_and_handles_missing_frontmatter() { + let updated = with_title("just a body\n", "Plain").unwrap(); + assert!(updated.starts_with("---\ntitle: Plain\n---\n\n")); + assert!(updated.ends_with("just a body\n")); + + let updated = with_title("---\ntags: [x]\n---\n\nbody\n", "Has: colon").unwrap(); + assert!(updated.contains("title: 'Has: colon'"), "got: {updated}"); + assert!(updated.contains("tags: [x]")); + } +} diff --git a/src-tauri/core/src/scan.rs b/src-tauri/core/src/scan.rs new file mode 100644 index 0000000..0ad1fcd --- /dev/null +++ b/src-tauri/core/src/scan.rs @@ -0,0 +1,391 @@ +use std::fs::File; +use std::io::Read; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use walkdir::{DirEntry, WalkDir}; + +use crate::frontmatter::{parse_doc_meta, split_frontmatter}; +use crate::workspace::migrate::marker_with_migration; +use crate::workspace::WorkspaceMarker; + +const PROGRESS_INTERVAL_MS: u64 = 100; +const MAX_FILES: usize = 50_000; +pub const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; +const PARTIAL_READ_BYTES: usize = 16 * 1024; +const MAX_HEADING_SCAN_LINES: usize = 120; + +pub trait ScanProgressSink: Send + Sync { + fn emit(&self, progress: &ScanProgress); +} + +pub struct NoopProgressSink; + +impl ScanProgressSink for NoopProgressSink { + fn emit(&self, _progress: &ScanProgress) {} +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MarkdownFile { + pub path: String, + pub name: String, + #[serde(rename = "relPath")] + pub rel_path: String, + pub title: Option, + pub tags: Vec, + pub modified: Option, + pub size: u64, + // Workspace-relative markdown files this doc links to. Extracted from + // the same partial read as title/tags, so links beyond the first 16 KiB + // are not seen. Backs the backlinks pane in the GUI. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub links: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ScanResult { + pub root: String, + pub files: Vec, + pub truncated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub marker: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ScanProgress { + pub root: String, + #[serde(rename = "currentDir")] + pub current_dir: String, + #[serde(rename = "filesFound")] + pub files_found: u64, + #[serde(rename = "dirsVisited")] + pub dirs_visited: u64, + #[serde(rename = "lastFile")] + pub last_file: Option, +} + +fn extract_first_heading(content: &str) -> Option { + for line in content.lines().take(MAX_HEADING_SCAN_LINES) { + let line = line.trim_start(); + if let Some(rest) = line.strip_prefix("# ") { + return Some(rest.trim().to_string()); + } + } + None +} + +fn parse_meta(content: &str) -> (Option, Vec) { + let (fm, _) = split_frontmatter(content); + let meta = fm.map(parse_doc_meta).unwrap_or_default(); + let title = meta.title.or_else(|| extract_first_heading(content)); + (title, meta.tags) +} + +const SKIP_DIRS: &[&str] = &[ + "node_modules", + "target", + ".git", + ".next", + "dist", + "build", + ".venv", + "venv", + ".cache", + ".turbo", + ".vercel", + ".idea", + ".vscode", + "Library", + "Applications", + "System", + "Pictures", + "Movies", + "Music", + ".Trash", + ".npm", + ".yarn", + ".pnpm-store", + ".cargo", + ".rustup", + ".bun", + ".local", + "Pods", + ".gradle", + "DerivedData", +]; + +fn is_skipped_dir(name: &str) -> bool { + if name.starts_with('.') { + return true; + } + SKIP_DIRS.contains(&name) +} + +pub(crate) fn is_markdown(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".md") || lower.ends_with(".markdown") || lower.ends_with(".mdx") +} + +fn read_partial(path: &Path) -> std::io::Result { + let mut file = File::open(path)?; + let mut buf = vec![0u8; PARTIAL_READ_BYTES]; + let n = file.read(&mut buf)?; + buf.truncate(n); + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result { + let root_path = Path::new(&path); + if !root_path.exists() { + return Err(format!("Path does not exist: {}", path)); + } + + let dirs_visited = Arc::new(AtomicU64::new(0)); + let last_emit = Arc::new(Mutex::new(Instant::now())); + + let mut entries: Vec = Vec::new(); + let mut truncated = false; + + let walker = WalkDir::new(root_path) + .follow_links(false) + .into_iter() + .filter_entry(|e| { + if e.depth() == 0 { + return true; + } + let name = e.file_name().to_string_lossy(); + if e.file_type().is_dir() { + !is_skipped_dir(&name) + } else { + !name.starts_with('.') + } + }); + + for entry in walker.filter_map(|e| e.ok()) { + if entry.file_type().is_dir() { + dirs_visited.fetch_add(1, Ordering::Relaxed); + maybe_emit_walk_progress( + progress, + &path, + entry.path(), + root_path, + &dirs_visited, + 0, + None, + &last_emit, + ); + continue; + } + + if !entry.file_type().is_file() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + if !is_markdown(&name) { + continue; + } + + if let Ok(meta) = entry.metadata() { + if meta.len() > MAX_FILE_BYTES { + continue; + } + } + + if entries.len() >= MAX_FILES { + truncated = true; + break; + } + entries.push(entry); + } + + let total_to_read = entries.len() as u64; + let files_processed = Arc::new(AtomicU64::new(0)); + + let mut files: Vec = entries + .par_iter() + .filter_map(|entry| { + let metadata = entry.metadata().ok()?; + let size = metadata.len(); + let modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + + let content = read_partial(entry.path()).ok()?; + let (title, tags) = parse_meta(&content); + + let rel_path = entry + .path() + .strip_prefix(root_path) + .unwrap_or(entry.path()) + .to_string_lossy() + .to_string(); + + let links = crate::links::links_from(&content, &rel_path); + + let count = files_processed.fetch_add(1, Ordering::Relaxed) + 1; + maybe_emit_read_progress( + progress, + &path, + count, + total_to_read, + Some(rel_path.clone()), + &last_emit, + ); + + Some(MarkdownFile { + path: entry.path().to_string_lossy().to_string(), + name: entry + .path() + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(), + rel_path, + title, + tags, + modified, + size, + links, + }) + }) + .collect(); + + files.sort_by(|a, b| a.rel_path.to_lowercase().cmp(&b.rel_path.to_lowercase())); + + progress.emit(&ScanProgress { + root: path.clone(), + current_dir: ".".to_string(), + files_found: files.len() as u64, + dirs_visited: dirs_visited.load(Ordering::Relaxed), + last_file: None, + }); + + // Best-effort: a broken marker must not stop the GUI from browsing the + // folder; the MCP path fails loud through resolve_workspace instead. + let marker = marker_with_migration(root_path).ok().flatten(); + + Ok(ScanResult { + root: root_path.to_string_lossy().to_string(), + files, + truncated, + marker, + }) +} + +fn should_emit(last_emit: &Mutex) -> bool { + let mut guard = match last_emit.lock() { + Ok(g) => g, + Err(_) => return false, + }; + if guard.elapsed() < Duration::from_millis(PROGRESS_INTERVAL_MS) { + return false; + } + *guard = Instant::now(); + true +} + +#[allow(clippy::too_many_arguments)] +fn maybe_emit_walk_progress( + progress: &dyn ScanProgressSink, + root: &str, + current: &Path, + root_path: &Path, + dirs_visited: &AtomicU64, + files_found: u64, + last_file: Option, + last_emit: &Mutex, +) { + if !should_emit(last_emit) { + return; + } + let rel = current + .strip_prefix(root_path) + .unwrap_or(current) + .to_string_lossy() + .to_string(); + progress.emit(&ScanProgress { + root: root.to_string(), + current_dir: if rel.is_empty() { ".".into() } else { rel }, + files_found, + dirs_visited: dirs_visited.load(Ordering::Relaxed), + last_file, + }); +} + +fn maybe_emit_read_progress( + progress: &dyn ScanProgressSink, + root: &str, + files_found: u64, + total: u64, + last_file: Option, + last_emit: &Mutex, +) { + if !should_emit(last_emit) { + return; + } + progress.emit(&ScanProgress { + root: root.to_string(), + current_dir: format!("reading {} of {}", files_found, total), + files_found, + dirs_visited: 0, + last_file, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::test_dir; + + #[test] + fn scan_migrates_legacy_manifest_and_reports_marker() { + let dir = test_dir("scan_migrates"); + std::fs::write( + dir.join(".docs.yaml"), + "project:\n slug: voice\n name: Vinfra Voice\n tagline: dropped\n", + ) + .unwrap(); + std::fs::write(dir.join("readme.md"), "# Hi\n").unwrap(); + + let result = run_scan(&NoopProgressSink, dir.to_string_lossy().to_string()).unwrap(); + let marker = result.marker.expect("marker migrated during scan"); + assert_eq!(marker.slug, "voice"); + assert_eq!(marker.name.as_deref(), Some("Vinfra Voice")); + assert!(dir.join(".docsreader.yaml").is_file()); + assert!(dir.join(".docs.yaml").is_file()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn scan_collects_resolved_relative_links() { + let dir = test_dir("scan_links"); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::write( + dir.join("sub/source.md"), + "# Src\nSee [target](../target.md) and [web](https://x.com/a.md).\n", + ) + .unwrap(); + std::fs::write(dir.join("target.md"), "# Target\n").unwrap(); + + let result = run_scan(&NoopProgressSink, dir.to_string_lossy().to_string()).unwrap(); + let source = result + .files + .iter() + .find(|f| f.rel_path.ends_with("source.md")) + .unwrap(); + assert_eq!(source.links, ["target.md"]); + let target = result + .files + .iter() + .find(|f| f.rel_path == "target.md") + .unwrap(); + assert!(target.links.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/slug.rs b/src-tauri/core/src/slug.rs new file mode 100644 index 0000000..ac9001c --- /dev/null +++ b/src-tauri/core/src/slug.rs @@ -0,0 +1,67 @@ +const FALLBACK_SLUG: &str = "untitled"; +const MAX_SLUG_CHARS: usize = 80; + +pub fn slugify(input: &str) -> String { + let mut slug = String::new(); + let mut pending_dash = false; + for c in input.chars() { + if c.is_alphanumeric() { + if pending_dash && !slug.is_empty() { + slug.push('-'); + } + slug.extend(c.to_lowercase()); + pending_dash = false; + } else { + pending_dash = true; + } + if slug.chars().count() >= MAX_SLUG_CHARS { + break; + } + } + if slug.is_empty() { + FALLBACK_SLUG.to_string() + } else { + slug + } +} + +pub fn unique_slug(base: &str, exists: impl Fn(&str) -> bool) -> String { + if !exists(base) { + return base.to_string(); + } + let mut n = 2u32; + loop { + let candidate = format!("{base}-{n}"); + if !exists(&candidate) { + return candidate; + } + n += 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugifies_titles() { + assert_eq!(slugify("Hello, World!"), "hello-world"); + assert_eq!(slugify(" --Weird__ spacing "), "weird-spacing"); + assert_eq!(slugify("API v2.0 Design"), "api-v2-0-design"); + assert_eq!(slugify("مرحبا بالعالم"), "مرحبا-بالعالم"); + assert_eq!(slugify("!!!"), "untitled"); + } + + #[test] + fn caps_slug_length() { + let long = "a".repeat(500); + assert_eq!(slugify(&long).chars().count(), MAX_SLUG_CHARS); + } + + #[test] + fn resolves_collisions_with_counter() { + let taken = ["doc", "doc-2"]; + assert_eq!(unique_slug("doc", |s| taken.contains(&s)), "doc-3"); + assert_eq!(unique_slug("fresh", |s| taken.contains(&s)), "fresh"); + } +} diff --git a/src-tauri/core/src/tasks.rs b/src-tauri/core/src/tasks.rs new file mode 100644 index 0000000..5e66392 --- /dev/null +++ b/src-tauri/core/src/tasks.rs @@ -0,0 +1,465 @@ +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use serde::Serialize; + +use crate::error::{CoreError, ErrorCode}; +use crate::frontmatter::{split_frontmatter, upsert_fm_line, yaml_line}; +use crate::update::str_replace_at; +use crate::write::{enforce_size_limit, stage_in_git}; + +pub const TASKS_DIR: &str = "tasks"; + +/// Backlog.md's DEFAULT_STATUSES, verbatim; the file format is theirs. +pub const TASK_STATUSES: [&str; 3] = ["To Do", "In Progress", "Done"]; +pub const TASK_PRIORITIES: [&str; 3] = ["high", "medium", "low"]; + +fn normalize_status(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .collect::() + .to_lowercase() +} + +/// Accepts any casing/spacing variant ("to-do", "In Progress", "done"). +pub fn parse_task_status(value: &str) -> Result<&'static str, CoreError> { + let wanted = normalize_status(value); + TASK_STATUSES + .into_iter() + .find(|s| normalize_status(s) == wanted) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidInput, + format!("unknown task status {value:?}"), + ) + .with_recovery(format!("valid statuses: [{}]", TASK_STATUSES.join(", "))) + }) +} + +fn parse_task_priority(value: &str) -> Result<&'static str, CoreError> { + TASK_PRIORITIES + .into_iter() + .find(|p| p.eq_ignore_ascii_case(value)) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidInput, + format!("unknown priority {value:?}"), + ) + .with_recovery(format!( + "valid priorities: [{}]", + TASK_PRIORITIES.join(", ") + )) + }) +} + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct TaskSummary { + pub id: String, + pub title: Option, + pub status: String, + pub assignee: Vec, + pub labels: Vec, + pub dependencies: Vec, + pub priority: Option, + pub created_date: Option, + pub updated_date: Option, + pub rel_path: String, + pub path: PathBuf, +} + +#[derive(Debug, Default)] +pub struct NewTask<'a> { + pub title: &'a str, + pub description: &'a str, + pub acceptance_criteria: Vec, + pub status: Option<&'a str>, + pub priority: Option<&'a str>, + pub assignee: Vec, + pub labels: Vec, + pub dependencies: Vec, + pub reporter: Option<&'a str>, +} + +const MAX_TITLE_FILE_CHARS: usize = 60; + +/// Backlog.md filename shape: "task-3 - Title-with-dashes.md". +fn task_file_name(id: &str, title: &str) -> String { + let mut sanitized = String::new(); + for c in title.chars() { + if c.is_alphanumeric() { + sanitized.push(c); + } else if !sanitized.ends_with('-') && !sanitized.is_empty() { + sanitized.push('-'); + } + } + let sanitized: String = sanitized + .trim_matches('-') + .chars() + .take(MAX_TITLE_FILE_CHARS) + .collect(); + let sanitized = sanitized.trim_matches('-'); + if sanitized.is_empty() { + format!("{id}.md") + } else { + format!("{id} - {sanitized}.md") + } +} + +fn yaml_list(key: &str, items: &[String]) -> Result { + if items.is_empty() { + return Ok(format!("{key}: []")); + } + let mut map = serde_yaml::Mapping::new(); + map.insert(key.into(), items.into()); + serde_yaml::to_string(&map) + .map(|s| s.trim_end().to_string()) + .map_err(|e| CoreError::new(ErrorCode::Io, format!("serialize {key}: {e}"))) +} + +fn render_task( + id: &str, + task: &NewTask<'_>, + status: &str, + today: &str, +) -> Result { + let mut fm = vec![ + format!("id: {id}"), + yaml_line("title", task.title)?, + yaml_line("status", status)?, + yaml_list("assignee", &task.assignee)?, + ]; + if let Some(reporter) = task.reporter { + fm.push(yaml_line("reporter", reporter)?); + } + // Dates are quoted the way Backlog.md's own serializer emits them, so + // YAML 1.1 parsers read strings, not timestamps. + fm.push(format!("created_date: '{today}'")); + fm.push(yaml_list("labels", &task.labels)?); + fm.push(yaml_list("dependencies", &task.dependencies)?); + if let Some(priority) = task.priority { + fm.push(yaml_line("priority", priority)?); + } + + let mut body = format!("## Description\n\n{}\n", task.description.trim_end()); + if !task.acceptance_criteria.is_empty() { + body.push_str("\n## Acceptance Criteria\n\n"); + for (i, criterion) in task.acceptance_criteria.iter().enumerate() { + body.push_str(&format!("- [ ] #{} {}\n", i + 1, criterion.trim())); + } + body.push_str("\n"); + } + Ok(format!("---\n{}\n---\n\n{body}", fm.join("\n"))) +} + +fn parse_task(path: &Path) -> Option { + if path.extension().is_none_or(|e| e != "md") || !path.is_file() { + return None; + } + let content = std::fs::read_to_string(path).ok()?; + let fm = split_frontmatter(&content).0?; + let map: serde_yaml::Mapping = serde_yaml::from_str(fm).ok()?; + let text = |key: &str| { + map.get(serde_yaml::Value::String(key.into())) + .and_then(|v| v.as_str()) + .map(str::to_string) + }; + let list = |key: &str| -> Vec { + map.get(serde_yaml::Value::String(key.into())) + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + }; + Some(TaskSummary { + id: text("id")?, + title: text("title"), + status: text("status").unwrap_or_else(|| TASK_STATUSES[0].to_string()), + assignee: list("assignee"), + labels: list("labels"), + dependencies: list("dependencies"), + priority: text("priority"), + created_date: text("created_date"), + updated_date: text("updated_date"), + rel_path: format!("{TASKS_DIR}/{}", path.file_name()?.to_string_lossy()), + path: path.to_path_buf(), + }) +} + +fn all_tasks(root: &Path) -> Vec { + let mut tasks: Vec = std::fs::read_dir(root.join(TASKS_DIR)) + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| parse_task(&entry.path())) + .collect(); + tasks.sort_by(|a, b| task_ordinal(&a.id).cmp(&task_ordinal(&b.id))); + tasks +} + +fn task_ordinal(id: &str) -> u64 { + id.rsplit('-') + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0) +} + +fn next_task_id(root: &Path) -> String { + let max = all_tasks(root) + .iter() + .map(|t| task_ordinal(&t.id)) + .max() + .unwrap_or(0); + format!("task-{}", max + 1) +} + +pub async fn write_task_core(root: &Path, task: &NewTask<'_>) -> Result { + if task.title.trim().is_empty() { + return Err(CoreError::new(ErrorCode::InvalidInput, "title is required") + .with_recovery("pass a short task title")); + } + let status = match task.status { + Some(s) => parse_task_status(s)?, + None => TASK_STATUSES[0], + }; + if let Some(p) = task.priority { + parse_task_priority(p)?; + } + let id = next_task_id(root); + let today = Utc::now().format("%Y-%m-%d").to_string(); + let rendered = render_task(&id, task, status, &today)?; + enforce_size_limit(rendered.len())?; + let rel_path = format!("{TASKS_DIR}/{}", task_file_name(&id, task.title)); + let path = root.join(&rel_path); + std::fs::create_dir_all(path.parent().unwrap_or(root))?; + std::fs::write(&path, rendered)?; + stage_in_git(root, &[&rel_path]).await; + parse_task(&path) + .ok_or_else(|| CoreError::new(ErrorCode::Io, format!("task {id} written but unreadable"))) +} + +pub fn list_tasks_core( + root: &Path, + status: Option<&str>, + label: Option<&str>, +) -> Result, CoreError> { + let status = status.map(parse_task_status).transpose()?; + Ok(all_tasks(root) + .into_iter() + .filter(|t| status.is_none_or(|s| t.status == s)) + .filter(|t| label.is_none_or(|l| t.labels.iter().any(|x| x == l))) + .collect()) +} + +/// Accepts a task id ("task-3") or a tasks/ relative path. +fn locate_task(root: &Path, task_ref: &str) -> Result { + let by_id = |id: &str| all_tasks(root).into_iter().find(|t| t.id == id); + let found = if let Some(name) = task_ref.strip_prefix("tasks/") { + all_tasks(root) + .into_iter() + .find(|t| t.rel_path == task_ref || t.path.file_name().is_some_and(|f| f == name)) + } else { + by_id(task_ref) + }; + found.ok_or_else(|| { + CoreError::new( + ErrorCode::DocNotFound, + format!("no task found for {task_ref:?}"), + ) + .with_recovery("pass a task id like \"task-3\"; see list_tasks") + }) +} + +fn touch_updated_date(content: &str) -> String { + let stamp = Utc::now().format("%Y-%m-%d %H:%M").to_string(); + upsert_fm_line(content, "updated_date", &format!("updated_date: '{stamp}'")) +} + +async fn rewrite_task(root: &Path, task: &TaskSummary, content: String) -> Result<(), CoreError> { + enforce_size_limit(content.len())?; + std::fs::write(&task.path, content)?; + stage_in_git(root, &[&task.rel_path]).await; + Ok(()) +} + +pub async fn set_task_status_core( + root: &Path, + task_ref: &str, + status: &str, +) -> Result { + let status = parse_task_status(status)?; + let task = locate_task(root, task_ref)?; + let content = std::fs::read_to_string(&task.path)?; + let line = yaml_line("status", status)?; + let updated = touch_updated_date(&upsert_fm_line(&content, "status", &line)); + rewrite_task(root, &task, updated).await?; + parse_task(&task.path) + .ok_or_else(|| CoreError::new(ErrorCode::Io, "task updated but unreadable")) +} + +/// str_replace on a task file (check acceptance criteria, extend sections). +pub async fn update_task_core( + root: &Path, + task_ref: &str, + old_str: &str, + new_str: &str, +) -> Result { + let task = locate_task(root, task_ref)?; + str_replace_at(root, &task.path, &task.rel_path, old_str, new_str).await?; + let content = std::fs::read_to_string(&task.path)?; + rewrite_task(root, &task, touch_updated_date(&content)).await?; + parse_task(&task.path) + .ok_or_else(|| CoreError::new(ErrorCode::Io, "task updated but unreadable")) +} + +pub async fn delete_task_core(root: &Path, task_ref: &str) -> Result { + let task = locate_task(root, task_ref)?; + std::fs::remove_file(&task.path)?; + stage_in_git(root, &[&task.rel_path]).await; + Ok(task) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dr_task_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn write_renders_backlog_md_shape_with_incrementing_ids() { + let root = test_dir("shape"); + let task = write_task_core( + &root, + &NewTask { + title: "Add core search functionality", + description: "Search across docs.", + acceptance_criteria: vec!["Results ranked".into(), "Budget enforced".into()], + labels: vec!["enhancement".into()], + priority: Some("medium"), + reporter: Some("claude-code"), + ..NewTask::default() + }, + ) + .await + .unwrap(); + assert_eq!(task.id, "task-1"); + assert_eq!( + task.rel_path, + "tasks/task-1 - Add-core-search-functionality.md" + ); + assert_eq!(task.status, "To Do"); + + let raw = std::fs::read_to_string(&task.path).unwrap(); + assert!(raw.starts_with("---\nid: task-1\ntitle: Add core search functionality\nstatus: To Do\nassignee: []\nreporter: claude-code\ncreated_date: '"), "exact backlog frontmatter order: {raw}"); + assert!(raw.contains("labels:\n- enhancement")); + assert!(raw.contains("dependencies: []")); + assert!(raw.contains("priority: medium")); + assert!(raw.contains("## Description\n\nSearch across docs.")); + assert!(raw.contains("## Acceptance Criteria\n\n- [ ] #1 Results ranked\n- [ ] #2 Budget enforced\n")); + + let next = write_task_core( + &root, + &NewTask { + title: "Second", + description: "x", + ..NewTask::default() + }, + ) + .await + .unwrap(); + assert_eq!(next.id, "task-2"); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn status_updates_in_frontmatter_and_lenient_parse() { + let root = test_dir("status"); + write_task_core( + &root, + &NewTask { + title: "Move me", + description: "x", + ..NewTask::default() + }, + ) + .await + .unwrap(); + + let moved = set_task_status_core(&root, "task-1", "in-progress") + .await + .unwrap(); + assert_eq!(moved.status, "In Progress"); + assert!(moved.updated_date.is_some()); + let raw = std::fs::read_to_string(&moved.path).unwrap(); + assert!(raw.contains("status: In Progress")); + assert!(raw.contains("updated_date: '")); + + let err = set_task_status_core(&root, "task-1", "blocked") + .await + .unwrap_err(); + assert!(err.recovery.unwrap().contains("To Do, In Progress, Done")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn update_checks_acceptance_criterion_and_list_filters() { + let root = test_dir("update"); + write_task_core( + &root, + &NewTask { + title: "With AC", + description: "x", + acceptance_criteria: vec!["Ship it".into()], + labels: vec!["mcp".into()], + ..NewTask::default() + }, + ) + .await + .unwrap(); + + update_task_core(&root, "task-1", "- [ ] #1 Ship it", "- [x] #1 Ship it") + .await + .unwrap(); + let raw = std::fs::read_to_string(root.join("tasks/task-1 - With-AC.md")).unwrap(); + assert!(raw.contains("- [x] #1 Ship it")); + + let done = list_tasks_core(&root, Some("done"), None).unwrap(); + assert!(done.is_empty()); + let tagged = list_tasks_core(&root, None, Some("mcp")).unwrap(); + assert_eq!(tagged.len(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn delete_removes_task_file() { + let root = test_dir("del"); + let task = write_task_core( + &root, + &NewTask { + title: "Doomed", + description: "x", + ..NewTask::default() + }, + ) + .await + .unwrap(); + delete_task_core(&root, "tasks/task-1 - Doomed.md") + .await + .unwrap(); + assert!(!task.path.exists()); + + let err = delete_task_core(&root, "task-1").await.unwrap_err(); + assert_eq!(err.code, ErrorCode::DocNotFound); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/core/src/update.rs b/src-tauri/core/src/update.rs new file mode 100644 index 0000000..ad5214e --- /dev/null +++ b/src-tauri/core/src/update.rs @@ -0,0 +1,146 @@ +use std::path::Path; + +use crate::error::{CoreError, ErrorCode}; +use crate::git; +use crate::write::{enforce_size_limit, locate_doc, WrittenDoc}; + +fn match_line_numbers(content: &str, needle: &str) -> Vec { + content + .match_indices(needle) + .map(|(offset, _)| content[..offset].matches('\n').count() + 1) + .collect() +} + +/// str_replace with the Anthropic memory-tool contract: old_str must appear +/// exactly once, and the error strings match that tool's reference wording +/// verbatim so agents trained on it recover the same way. +pub async fn str_replace_core( + root: &Path, + doc_ref: &str, + old_str: &str, + new_str: &str, +) -> Result { + let doc = locate_doc(root, doc_ref)?; + str_replace_at(root, &doc.path, &doc.rel_path, old_str, new_str).await?; + Ok(doc.to_written()) +} + +/// The memory-tool str_replace contract applied to an already-located file. +pub(crate) async fn str_replace_at( + root: &Path, + path: &std::path::Path, + rel_path: &str, + old_str: &str, + new_str: &str, +) -> Result<(), CoreError> { + if old_str.is_empty() { + return Err( + CoreError::new(ErrorCode::InvalidInput, "old_str must not be empty") + .with_recovery("pass the exact text to replace"), + ); + } + let content = std::fs::read_to_string(path)?; + let lines = match_line_numbers(&content, old_str); + match lines.len() { + 0 => Err(CoreError::new( + ErrorCode::InvalidInput, + format!( + "No replacement was performed, old_str `{old_str}` did not appear verbatim in {rel_path}." + ), + )), + 1 => { + let updated = content.replacen(old_str, new_str, 1); + enforce_size_limit(updated.len())?; + std::fs::write(path, updated)?; + let root_str = root.to_string_lossy(); + if git::is_git_repo(&root_str).await { + git::git_add(&root_str, &[rel_path]).await; + } + Ok(()) + } + _ => { + let mut unique_lines = lines; + unique_lines.dedup(); + let listed: Vec = unique_lines.iter().map(usize::to_string).collect(); + Err(CoreError::new( + ErrorCode::InvalidInput, + format!( + "No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines: {}. Please ensure it is unique", + listed.join(", ") + ), + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::write::{write_doc_core, DocStatus, NewDoc}; + + fn test_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_upd_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn replaces_a_unique_match() { + let root = test_dir("ok"); + let doc = write_doc_core( + &root, + &NewDoc::new("Prefs", "Favorite color: blue", DocStatus::Research), + ) + .await + .unwrap(); + + str_replace_core(&root, &doc.slug, "blue", "green") + .await + .unwrap(); + let raw = std::fs::read_to_string(&doc.path).unwrap(); + assert!(raw.contains("Favorite color: green")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn no_match_uses_verbatim_memory_tool_error() { + let root = test_dir("nomatch"); + let doc = write_doc_core(&root, &NewDoc::new("Prefs", "text", DocStatus::Research)) + .await + .unwrap(); + + let err = str_replace_core(&root, &doc.slug, "absent", "x") + .await + .unwrap_err(); + assert_eq!( + err.message, + "No replacement was performed, old_str `absent` did not appear verbatim in research/prefs.md." + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn duplicate_match_lists_lines_and_asks_for_unique() { + let root = test_dir("dupe"); + let doc = write_doc_core( + &root, + &NewDoc::new("Dupes", "alpha\nother\nalpha", DocStatus::Research), + ) + .await + .unwrap(); + + let err = str_replace_core(&root, &doc.slug, "alpha", "x") + .await + .unwrap_err(); + assert!( + err.message.starts_with( + "No replacement was performed. Multiple occurrences of old_str `alpha` in lines:" + ), + "got: {}", + err.message + ); + assert!(err.message.ends_with("Please ensure it is unique")); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/core/src/workspace/init.rs b/src-tauri/core/src/workspace/init.rs new file mode 100644 index 0000000..8c7ebb0 --- /dev/null +++ b/src-tauri/core/src/workspace/init.rs @@ -0,0 +1,244 @@ +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use super::registry::{load_registry, upsert_workspace, WorkspaceEntry}; +use super::{load_marker, save_marker, WorkspaceMarker, WorkspaceScope, MARKER_FILE}; +use crate::error::{CoreError, ErrorCode}; +use crate::slug::slugify; +use crate::write::DocStatus; + +#[derive(Debug, Serialize)] +pub struct InitializedWorkspace { + pub root: PathBuf, + pub slug: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub scope: WorkspaceScope, +} + +fn default_slug(root: &Path, scope: WorkspaceScope) -> String { + if scope == WorkspaceScope::User { + return super::resolve::DEFAULT_USER_SLUG.to_string(); + } + let parent_name = root + .parent() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + slugify(&parent_name) +} + +fn ensure_target_is_fresh(root: &Path) -> Result<(), CoreError> { + if !root.exists() { + return Ok(()); + } + if load_marker(root)?.is_some() { + return Err(CoreError::new( + ErrorCode::Conflict, + format!("{} is already a DocsReader workspace", root.display()), + ) + .with_recovery("it is ready to use; call write_doc or list_workspaces")); + } + let has_content = std::fs::read_dir(root)?.next().is_some(); + if has_content { + return Err(CoreError::new( + ErrorCode::Conflict, + format!( + "{} already has content but no {MARKER_FILE}", + root.display() + ), + ) + .with_recovery("pick an empty or new directory, or convert it in the DocsReader app")); + } + Ok(()) +} + +fn materialize_workspace( + root: &Path, + slug: String, + name: Option<&str>, + scope: WorkspaceScope, + registry_file: &Path, +) -> Result { + let marker = WorkspaceMarker { + slug: slug.clone(), + name: name.map(str::to_string), + homepage: None, + }; + save_marker(root, &marker)?; + for status in DocStatus::ALL { + std::fs::create_dir_all(root.join(status.folder()))?; + } + upsert_workspace( + registry_file, + WorkspaceEntry { + slug: slug.clone(), + path: root.to_path_buf(), + scope, + }, + )?; + Ok(InitializedWorkspace { + root: root.to_path_buf(), + slug, + name: name.map(str::to_string), + scope, + }) +} + +pub fn init_workspace_core( + root: &Path, + slug: Option<&str>, + name: Option<&str>, + scope: WorkspaceScope, + registry_file: &Path, +) -> Result { + ensure_target_is_fresh(root)?; + let slug = match slug { + Some(s) => s.to_string(), + None => default_slug(root, scope), + }; + materialize_workspace(root, slug, name, scope, registry_file) +} + +/// Converts an existing folder of markdown into a managed workspace in place: +/// marker, status folders, registry entry. This is the GUI's answer to init's +/// "already has content" conflict; the slug derives from the folder name and +/// is suffixed if another registered workspace already uses it. +pub fn convert_workspace_core( + root: &Path, + registry_file: &Path, +) -> Result { + if load_marker(root)?.is_some() { + return Err(CoreError::new( + ErrorCode::Conflict, + format!("{} is already a DocsReader workspace", root.display()), + ) + .with_recovery("it is ready to use as-is")); + } + let base = root + .file_name() + .and_then(|n| n.to_str()) + .map(slugify) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidInput, + format!("cannot derive a workspace slug from {}", root.display()), + ) + })?; + let slug = free_slug(base, root, registry_file)?; + materialize_workspace(root, slug, None, WorkspaceScope::Project, registry_file) +} + +fn free_slug(base: String, root: &Path, registry_file: &Path) -> Result { + let entries = load_registry(registry_file)?; + let taken = |slug: &str| entries.iter().any(|e| e.slug == slug && e.path != root); + if !taken(&base) { + return Ok(base); + } + Ok((2..) + .map(|n| format!("{base}-{n}")) + .find(|candidate| !taken(candidate)) + .expect("unbounded suffix search always finds a free slug")) +} + +#[cfg(test)] +mod tests { + use super::super::test_dir; + use super::*; + + #[test] + fn init_creates_marker_status_folders_and_registers() { + let dir = test_dir("init_ok"); + let root = dir.join("myrepo/notes"); + let registry = dir.join("registry.json"); + + let ws = + init_workspace_core(&root, None, None, WorkspaceScope::Project, ®istry).unwrap(); + assert_eq!(ws.slug, "myrepo", "slug derives from parent folder"); + for folder in ["research", "in-progress", "done", "archived"] { + assert!(root.join(folder).is_dir(), "missing {folder}"); + } + assert!(load_marker(&root).unwrap().is_some()); + + let entries = super::super::registry::load_registry(®istry).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].slug, "myrepo"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn init_on_existing_workspace_is_conflict() { + let dir = test_dir("init_twice"); + let root = dir.join("notes"); + let registry = dir.join("registry.json"); + init_workspace_core(&root, None, None, WorkspaceScope::User, ®istry).unwrap(); + + let err = + init_workspace_core(&root, None, None, WorkspaceScope::User, ®istry).unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn convert_turns_populated_folder_into_workspace() { + let dir = test_dir("convert_ok"); + let root = dir.join("my-notes"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("existing.md"), "# Hi\n").unwrap(); + let registry = dir.join("registry.json"); + + let ws = convert_workspace_core(&root, ®istry).unwrap(); + assert_eq!(ws.slug, "my-notes"); + assert_eq!(ws.scope, WorkspaceScope::Project); + assert!(load_marker(&root).unwrap().is_some()); + for folder in ["research", "in-progress", "done", "archived"] { + assert!(root.join(folder).is_dir(), "missing {folder}"); + } + assert!(root.join("existing.md").is_file(), "content untouched"); + + let err = convert_workspace_core(&root, ®istry).unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict, "second convert conflicts"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn convert_suffixes_slug_taken_by_another_path() { + let dir = test_dir("convert_dupe"); + let registry = dir.join("registry.json"); + let first = dir.join("a/notes"); + let second = dir.join("b/notes"); + std::fs::create_dir_all(&first).unwrap(); + std::fs::create_dir_all(&second).unwrap(); + + assert_eq!( + convert_workspace_core(&first, ®istry).unwrap().slug, + "notes" + ); + assert_eq!( + convert_workspace_core(&second, ®istry).unwrap().slug, + "notes-2" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn init_on_populated_non_workspace_dir_is_conflict() { + let dir = test_dir("init_populated"); + let root = dir.join("stuff"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("existing.txt"), "x").unwrap(); + + let err = init_workspace_core( + &root, + None, + None, + WorkspaceScope::Project, + &dir.join("r.json"), + ) + .unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/workspace/migrate.rs b/src-tauri/core/src/workspace/migrate.rs new file mode 100644 index 0000000..79fdd0b --- /dev/null +++ b/src-tauri/core/src/workspace/migrate.rs @@ -0,0 +1,148 @@ +use std::path::Path; + +use serde::Deserialize; + +use super::{load_marker, save_marker, WorkspaceMarker}; +use crate::error::CoreError; +use crate::slug::slugify; + +const LEGACY_FILES: [&str; 2] = [".docs.yaml", "docs.yaml"]; + +#[derive(Debug, Default, Deserialize)] +struct LegacyProject { + slug: Option, + name: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct LegacyManifest { + project: Option, +} + +/// Loads the workspace marker, migrating a legacy `.docs.yaml`/`docs.yaml` +/// manifest on first sight: only the project slug and name carry over, and +/// the legacy file stays in place for one release so older builds keep +/// working. Folders whose legacy manifest names no project are left alone - +/// a bare `docs.yaml` is not proof the folder was a DocsReader workspace. +pub fn marker_with_migration(root: &Path) -> Result, CoreError> { + if let Some(marker) = load_marker(root)? { + return Ok(Some(marker)); + } + let Some(project) = legacy_project(root) else { + return Ok(None); + }; + let Some(slug) = [project.slug.as_deref(), project.name.as_deref()] + .into_iter() + .flatten() + .map(slugify) + .find(|s| !s.is_empty()) + else { + return Ok(None); + }; + let name = project + .name + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()); + let marker = WorkspaceMarker { + slug, + name, + homepage: None, + }; + save_marker(root, &marker)?; + Ok(Some(marker)) +} + +fn legacy_project(root: &Path) -> Option { + LEGACY_FILES + .iter() + .find_map(|name| std::fs::read_to_string(root.join(name)).ok()) + .and_then(|raw| serde_yaml::from_str::(&raw).ok()) + .and_then(|manifest| manifest.project) +} + +#[cfg(test)] +mod tests { + use super::super::{test_dir, MARKER_FILE}; + use super::*; + + const RICH_LEGACY: &str = r##" +spec_version: "0.1" +project: + slug: voice + name: Vinfra Voice + tagline: Carrier-grade VoIP + icon: phone + homepage: docs/spec/architecture.md +navigation: + - title: Start here + items: + - title: Overview + path: docs/overview.md +ignore: + - docs/archived/** +visibility: internal +"##; + + #[test] + fn migrates_legacy_manifest_keeping_slug_and_name_only() { + let dir = test_dir("mig_rich"); + std::fs::write(dir.join(".docs.yaml"), RICH_LEGACY).unwrap(); + + let marker = marker_with_migration(&dir).unwrap().unwrap(); + assert_eq!(marker.slug, "voice"); + assert_eq!(marker.name.as_deref(), Some("Vinfra Voice")); + assert_eq!(marker.homepage, None); + + let written = std::fs::read_to_string(dir.join(MARKER_FILE)).unwrap(); + for dropped in ["tagline", "icon", "navigation", "ignore", "visibility"] { + assert!(!written.contains(dropped), "{dropped} should be dropped"); + } + assert!( + dir.join(".docs.yaml").exists(), + "legacy file stays for one release" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn existing_marker_wins_over_legacy_manifest() { + let dir = test_dir("mig_marker_wins"); + std::fs::write(dir.join(MARKER_FILE), "slug: existing\n").unwrap(); + std::fs::write(dir.join(".docs.yaml"), RICH_LEGACY).unwrap(); + + let marker = marker_with_migration(&dir).unwrap().unwrap(); + assert_eq!(marker.slug, "existing"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn foreign_docs_yaml_without_project_is_ignored() { + let dir = test_dir("mig_foreign"); + std::fs::write(dir.join("docs.yaml"), "site_name: Some Other Tool\n").unwrap(); + + assert_eq!(marker_with_migration(&dir).unwrap(), None); + assert!(!dir.join(MARKER_FILE).exists(), "no marker written"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn non_slug_legacy_values_are_slugified() { + let dir = test_dir("mig_badslug"); + std::fs::write( + dir.join(".docs.yaml"), + "project:\n slug: \"Bad Slug!\"\n name: Bad Slug\n", + ) + .unwrap(); + + let marker = marker_with_migration(&dir).unwrap().unwrap(); + assert_eq!(marker.slug, "bad-slug"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn plain_folder_yields_none() { + let dir = test_dir("mig_plain"); + assert_eq!(marker_with_migration(&dir).unwrap(), None); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/workspace/mod.rs b/src-tauri/core/src/workspace/mod.rs new file mode 100644 index 0000000..7657dd8 --- /dev/null +++ b/src-tauri/core/src/workspace/mod.rs @@ -0,0 +1,129 @@ +pub mod init; +pub mod migrate; +pub mod registry; +pub mod resolve; + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::error::{CoreError, ErrorCode}; +use crate::slug::slugify; + +pub const MARKER_FILE: &str = ".docsreader.yaml"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceScope { + User, + Project, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceMarker { + pub slug: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub homepage: Option, +} + +fn validate_slug(slug: &str) -> Result<(), CoreError> { + if slug.is_empty() || slugify(slug) != slug { + return Err(CoreError::new( + ErrorCode::InvalidInput, + format!("invalid workspace slug {slug:?}"), + ) + .with_recovery( + "slugs are lowercase alphanumerics separated by dashes, e.g. \"my-project\"", + )); + } + Ok(()) +} + +pub fn load_marker(dir: &Path) -> Result, CoreError> { + let path = dir.join(MARKER_FILE); + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + let marker: WorkspaceMarker = serde_yaml::from_str(&raw).map_err(|e| { + CoreError::new( + ErrorCode::InvalidInput, + format!("malformed {MARKER_FILE}: {e}"), + ) + .with_recovery("the marker needs at least a `slug:` line") + })?; + validate_slug(&marker.slug)?; + Ok(Some(marker)) +} + +pub fn save_marker(dir: &Path, marker: &WorkspaceMarker) -> Result<(), CoreError> { + validate_slug(&marker.slug)?; + let raw = serde_yaml::to_string(marker) + .map_err(|e| CoreError::new(ErrorCode::Io, format!("serialize {MARKER_FILE}: {e}")))?; + std::fs::create_dir_all(dir)?; + std::fs::write(dir.join(MARKER_FILE), raw)?; + Ok(()) +} + +#[cfg(test)] +pub(crate) fn test_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dr_ws_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marker_round_trips() { + let dir = test_dir("marker_rt"); + let marker = WorkspaceMarker { + slug: "my-project".into(), + name: Some("My Project".into()), + homepage: Some("index.md".into()), + }; + save_marker(&dir, &marker).unwrap(); + assert_eq!(load_marker(&dir).unwrap(), Some(marker)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn missing_marker_is_none() { + let dir = test_dir("marker_none"); + assert_eq!(load_marker(&dir).unwrap(), None); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn missing_slug_is_typed_error() { + let dir = test_dir("marker_noslug"); + std::fs::write(dir.join(MARKER_FILE), "name: No Slug Here\n").unwrap(); + let err = load_marker(&dir).unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidInput); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn non_slug_value_rejected_on_save_and_load() { + let dir = test_dir("marker_badslug"); + let bad = WorkspaceMarker { + slug: "Bad Slug!".into(), + name: None, + homepage: None, + }; + assert_eq!( + save_marker(&dir, &bad).unwrap_err().code, + ErrorCode::InvalidInput + ); + std::fs::write(dir.join(MARKER_FILE), "slug: \"Bad Slug!\"\n").unwrap(); + assert_eq!(load_marker(&dir).unwrap_err().code, ErrorCode::InvalidInput); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/workspace/registry.rs b/src-tauri/core/src/workspace/registry.rs new file mode 100644 index 0000000..0ca1f25 --- /dev/null +++ b/src-tauri/core/src/workspace/registry.rs @@ -0,0 +1,113 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::WorkspaceScope; +use crate::error::{CoreError, ErrorCode}; + +pub const REGISTRY_DIR: &str = ".docsreader"; +pub const REGISTRY_FILE: &str = "workspaces.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceEntry { + pub slug: String, + pub path: PathBuf, + pub scope: WorkspaceScope, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct RegistryFile { + workspaces: Vec, +} + +pub fn default_registry_path(home: &Path) -> PathBuf { + home.join(REGISTRY_DIR).join(REGISTRY_FILE) +} + +pub fn load_registry(file: &Path) -> Result, CoreError> { + let raw = match std::fs::read_to_string(file) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; + let parsed: RegistryFile = serde_json::from_str(&raw).map_err(|e| { + CoreError::new( + ErrorCode::InvalidInput, + format!("malformed workspace registry: {e}"), + ) + .with_recovery(format!("delete or fix {}", file.display())) + })?; + Ok(parsed.workspaces) +} + +pub fn save_registry(file: &Path, workspaces: &[WorkspaceEntry]) -> Result<(), CoreError> { + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent)?; + } + let raw = serde_json::to_string_pretty(&RegistryFile { + workspaces: workspaces.to_vec(), + }) + .map_err(|e| CoreError::new(ErrorCode::Io, format!("serialize registry: {e}")))?; + std::fs::write(file, raw)?; + Ok(()) +} + +/// Replaces any entry with the same path, so re-registering updates slug/scope. +pub fn upsert_workspace(file: &Path, entry: WorkspaceEntry) -> Result<(), CoreError> { + let mut workspaces = load_registry(file)?; + workspaces.retain(|w| w.path != entry.path); + workspaces.push(entry); + save_registry(file, &workspaces) +} + +#[cfg(test)] +mod tests { + use super::super::test_dir; + use super::*; + + fn entry(slug: &str, path: &str, scope: WorkspaceScope) -> WorkspaceEntry { + WorkspaceEntry { + slug: slug.into(), + path: PathBuf::from(path), + scope, + } + } + + #[test] + fn missing_registry_is_empty() { + let dir = test_dir("reg_missing"); + assert_eq!(load_registry(&dir.join("nope.json")).unwrap(), Vec::new()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_round_trips_and_dedupes_by_path() { + let dir = test_dir("reg_upsert"); + let file = dir.join(REGISTRY_FILE); + upsert_workspace(&file, entry("notes", "/home/u/notes", WorkspaceScope::User)).unwrap(); + upsert_workspace(&file, entry("proj", "/repo/notes", WorkspaceScope::Project)).unwrap(); + upsert_workspace( + &file, + entry("renamed", "/repo/notes", WorkspaceScope::Project), + ) + .unwrap(); + + let entries = load_registry(&file).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].slug, "notes"); + assert_eq!(entries[1].slug, "renamed"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn malformed_registry_is_typed_error() { + let dir = test_dir("reg_bad"); + let file = dir.join(REGISTRY_FILE); + std::fs::write(&file, "not json").unwrap(); + assert_eq!( + load_registry(&file).unwrap_err().code, + ErrorCode::InvalidInput + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/workspace/resolve.rs b/src-tauri/core/src/workspace/resolve.rs new file mode 100644 index 0000000..9c20170 --- /dev/null +++ b/src-tauri/core/src/workspace/resolve.rs @@ -0,0 +1,257 @@ +use std::path::{Path, PathBuf}; + +use super::registry::WorkspaceEntry; +use super::{load_marker, WorkspaceScope}; +use crate::error::{CoreError, ErrorCode}; + +pub const DEFAULT_WORKSPACE_DIR: &str = "notes"; +pub const DEFAULT_USER_SLUG: &str = "notes"; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ResolvedWorkspace { + pub root: PathBuf, + pub slug: String, + pub scope: WorkspaceScope, +} + +/// Resolution order mirrors Claude Code's user+project hierarchy: +/// explicit slug > client roots hint > walk-up from cwd > default user +/// workspace. An explicit slug matches the registry or the ambient +/// (roots/walk-up/default) workspace, so every slug the server reports is +/// resolvable even before registration. Only an explicit slug matching +/// neither fails. +pub fn resolve_workspace( + explicit_slug: Option<&str>, + roots_hint: &[PathBuf], + cwd: &Path, + home: &Path, + registry: &[WorkspaceEntry], +) -> Result { + let ambient = ambient_workspace(roots_hint, cwd, home)?; + match explicit_slug { + Some(slug) => resolve_explicit(slug, registry, ambient), + None => Ok(ambient), + } +} + +fn ambient_workspace( + roots_hint: &[PathBuf], + cwd: &Path, + home: &Path, +) -> Result { + for base in roots_hint.iter().map(PathBuf::as_path).chain(walk_up(cwd)) { + if let Some(found) = project_workspace_at(base)? { + return Ok(found); + } + } + user_default(home) +} + +fn resolve_explicit( + slug: &str, + registry: &[WorkspaceEntry], + ambient: ResolvedWorkspace, +) -> Result { + if let Some(entry) = registry.iter().find(|w| w.slug == slug && w.path.is_dir()) { + return Ok(ResolvedWorkspace { + root: entry.path.clone(), + slug: entry.slug.clone(), + scope: entry.scope, + }); + } + if ambient.slug == slug && ambient.root.is_dir() { + return Ok(ambient); + } + let available = available_slugs(registry, Some(&ambient)); + Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + format!("no workspace with slug {slug:?}"), + ) + .with_recovery(format!( + "available workspaces: [{}]; call list_workspaces or init_workspace", + available.join(", ") + ))) +} + +/// Slugs that would resolve right now: registered workspaces whose directory +/// still exists, plus the ambient workspace when it exists unregistered. +pub fn available_slugs( + registry: &[WorkspaceEntry], + ambient: Option<&ResolvedWorkspace>, +) -> Vec { + let mut available: Vec = registry + .iter() + .filter(|w| w.path.is_dir()) + .map(|w| w.slug.clone()) + .collect(); + if let Some(ambient) = ambient { + if ambient.root.is_dir() && !available.iter().any(|s| s == &ambient.slug) { + available.push(ambient.slug.clone()); + } + } + available +} + +fn walk_up(cwd: &Path) -> impl Iterator { + cwd.ancestors() +} + +fn project_workspace_at(base: &Path) -> Result, CoreError> { + let candidate = base.join(DEFAULT_WORKSPACE_DIR); + match load_marker(&candidate)? { + Some(marker) => Ok(Some(ResolvedWorkspace { + root: candidate, + slug: marker.slug, + scope: WorkspaceScope::Project, + })), + None => Ok(None), + } +} + +fn user_default(home: &Path) -> Result { + let root = home.join(DEFAULT_WORKSPACE_DIR); + let slug = match load_marker(&root)? { + Some(marker) => marker.slug, + None => DEFAULT_USER_SLUG.to_string(), + }; + Ok(ResolvedWorkspace { + root, + slug, + scope: WorkspaceScope::User, + }) +} + +#[cfg(test)] +mod tests { + use super::super::{save_marker, test_dir, WorkspaceMarker}; + use super::*; + + fn marker(slug: &str) -> WorkspaceMarker { + WorkspaceMarker { + slug: slug.into(), + name: None, + homepage: None, + } + } + + #[test] + fn walk_up_finds_project_workspace_from_nested_cwd() { + let dir = test_dir("res_walkup"); + let project = dir.join("repo"); + save_marker(&project.join("notes"), &marker("repo-notes")).unwrap(); + let cwd = project.join("src/deeply/nested"); + std::fs::create_dir_all(&cwd).unwrap(); + + let resolved = resolve_workspace(None, &[], &cwd, &dir.join("home"), &[]).unwrap(); + assert_eq!(resolved.slug, "repo-notes"); + assert_eq!(resolved.scope, WorkspaceScope::Project); + assert_eq!(resolved.root, project.join("notes")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn roots_hint_wins_over_cwd_walk_up() { + let dir = test_dir("res_roots"); + let hinted = dir.join("hinted"); + save_marker(&hinted.join("notes"), &marker("hinted-notes")).unwrap(); + let other = dir.join("other"); + save_marker(&other.join("notes"), &marker("other-notes")).unwrap(); + + let resolved = resolve_workspace( + None, + std::slice::from_ref(&hinted), + &other, + &dir.join("home"), + &[], + ) + .unwrap(); + assert_eq!(resolved.slug, "hinted-notes"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn no_marker_defaults_to_user_workspace() { + let dir = test_dir("res_default"); + let home = dir.join("home"); + let cwd = dir.join("elsewhere"); + std::fs::create_dir_all(&cwd).unwrap(); + + let resolved = resolve_workspace(None, &[], &cwd, &home, &[]).unwrap(); + assert_eq!(resolved.root, home.join("notes")); + assert_eq!(resolved.slug, DEFAULT_USER_SLUG); + assert_eq!(resolved.scope, WorkspaceScope::User); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn user_workspace_marker_slug_wins_over_default() { + let dir = test_dir("res_userslug"); + let home = dir.join("home"); + save_marker(&home.join("notes"), &marker("ali-notes")).unwrap(); + + let resolved = resolve_workspace(None, &[], &dir, &home, &[]).unwrap(); + assert_eq!(resolved.slug, "ali-notes"); + assert_eq!(resolved.scope, WorkspaceScope::User); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn explicit_slug_resolves_from_registry() { + let dir = test_dir("res_explicit"); + let ws = dir.join("proj/notes"); + save_marker(&ws, &marker("proj")).unwrap(); + let registry = vec![WorkspaceEntry { + slug: "proj".into(), + path: ws.clone(), + scope: WorkspaceScope::Project, + }]; + + let resolved = resolve_workspace(Some("proj"), &[], &dir, &dir, ®istry).unwrap(); + assert_eq!(resolved.root, ws); + assert_eq!(resolved.scope, WorkspaceScope::Project); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn explicit_slug_matches_unregistered_walk_up_workspace() { + let dir = test_dir("res_ambient"); + let project = dir.join("repo"); + save_marker(&project.join("notes"), &marker("repo-notes")).unwrap(); + + let resolved = + resolve_workspace(Some("repo-notes"), &[], &project, &dir.join("home"), &[]).unwrap(); + assert_eq!(resolved.root, project.join("notes")); + assert_eq!(resolved.scope, WorkspaceScope::Project); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unknown_slug_recovery_lists_ambient_workspace() { + let dir = test_dir("res_ambient_list"); + let project = dir.join("repo"); + save_marker(&project.join("notes"), &marker("repo-notes")).unwrap(); + + let err = + resolve_workspace(Some("ghost"), &[], &project, &dir.join("home"), &[]).unwrap_err(); + assert!(err.recovery.as_deref().unwrap().contains("repo-notes")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn explicit_unknown_slug_fails_loud_with_available_list() { + let dir = test_dir("res_unknown"); + let ws = dir.join("known/notes"); + save_marker(&ws, &marker("known")).unwrap(); + let registry = vec![WorkspaceEntry { + slug: "known".into(), + path: ws, + scope: WorkspaceScope::Project, + }]; + + let err = resolve_workspace(Some("ghost"), &[], &dir, &dir, ®istry).unwrap_err(); + assert_eq!(err.code, ErrorCode::WorkspaceNotFound); + assert!(err.recovery.as_deref().unwrap().contains("known")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/write.rs b/src-tauri/core/src/write.rs new file mode 100644 index 0000000..945c644 --- /dev/null +++ b/src-tauri/core/src/write.rs @@ -0,0 +1,586 @@ +use std::path::{Path, PathBuf}; + +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::{CoreError, ErrorCode}; +use crate::git; +use crate::scan::MAX_FILE_BYTES; +use crate::slug::{slugify, unique_slug}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum DocStatus { + Research, + InProgress, + Done, + Archived, +} + +impl DocStatus { + pub const ALL: [Self; 4] = [Self::Research, Self::InProgress, Self::Done, Self::Archived]; + + pub fn folder(self) -> &'static str { + match self { + Self::Research => "research", + Self::InProgress => "in-progress", + Self::Done => "done", + Self::Archived => "archived", + } + } + + pub fn parse(value: &str) -> Result { + Self::ALL + .into_iter() + .find(|s| s.folder() == value) + .ok_or_else(|| { + let known: Vec<&str> = Self::ALL.into_iter().map(Self::folder).collect(); + CoreError::new(ErrorCode::InvalidInput, format!("unknown status {value:?}")) + .with_recovery(format!("valid statuses: [{}]", known.join(", "))) + }) + } +} + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct WrittenDoc { + pub slug: String, + pub rel_path: String, + pub path: PathBuf, + pub status: DocStatus, + pub phase: Option, +} + +#[derive(Debug, Clone)] +pub struct DocLocation { + pub slug: String, + pub status: DocStatus, + pub phase: Option, + pub rel_path: String, + pub path: PathBuf, +} + +impl DocLocation { + pub(crate) fn to_written(&self) -> WrittenDoc { + WrittenDoc { + slug: self.slug.clone(), + rel_path: self.rel_path.clone(), + path: self.path.clone(), + status: self.status, + phase: self.phase.clone(), + } + } +} + +#[derive(Debug)] +pub struct NewDoc<'a> { + pub title: &'a str, + pub body: &'a str, + pub status: DocStatus, + pub created_by: Option<&'a str>, + pub phase: Option<&'a str>, + pub owner: Option<&'a str>, + pub tags: Vec, + pub priority: Option<&'a str>, + pub due: Option<&'a str>, +} + +impl<'a> NewDoc<'a> { + pub fn new(title: &'a str, body: &'a str, status: DocStatus) -> Self { + Self { + title, + body, + status, + created_by: None, + phase: None, + owner: None, + tags: Vec::new(), + priority: None, + due: None, + } + } +} + +#[derive(Serialize)] +struct Frontmatter<'a> { + title: &'a str, + created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + created_by: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + owner: Option<&'a str>, + #[serde(skip_serializing_if = "<[String]>::is_empty")] + tags: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + priority: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + due: Option<&'a str>, +} + +fn doc_rel_path(status: DocStatus, phase: Option<&str>, slug: &str) -> String { + match phase { + Some(p) => format!("{}/{p}/{slug}.md", status.folder()), + None => format!("{}/{slug}.md", status.folder()), + } +} + +pub(crate) fn location_at( + root: &Path, + status: DocStatus, + phase: Option<&str>, + slug: &str, +) -> DocLocation { + let rel_path = doc_rel_path(status, phase, slug); + DocLocation { + slug: slug.to_string(), + status, + phase: phase.map(str::to_string), + rel_path: rel_path.clone(), + path: root.join(rel_path), + } +} + +fn phases_in(root: &Path, status: DocStatus) -> Vec { + let Ok(entries) = std::fs::read_dir(root.join(status.folder())) else { + return Vec::new(); + }; + entries + .flatten() + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect() +} + +fn validate_phase(phase: &str) -> Result<(), CoreError> { + if phase.is_empty() || slugify(phase) != phase { + return Err( + CoreError::new(ErrorCode::InvalidInput, format!("invalid phase {phase:?}")) + .with_recovery( + "phases are lowercase alphanumerics separated by dashes, e.g. \"v2-launch\"", + ), + ); + } + Ok(()) +} + +pub(crate) fn find_doc_location(root: &Path, slug: &str) -> Option { + for status in DocStatus::ALL { + let direct = location_at(root, status, None, slug); + if direct.path.is_file() { + return Some(direct); + } + for phase in phases_in(root, status) { + let nested = location_at(root, status, Some(&phase), slug); + if nested.path.is_file() { + return Some(nested); + } + } + } + None +} + +fn doc_not_found(doc_ref: &str) -> CoreError { + CoreError::new( + ErrorCode::DocNotFound, + format!("no doc found for {doc_ref:?}"), + ) + .with_recovery( + "pass a slug or a status-relative path like \"research/my-doc.md\"; see list_docs", + ) +} + +/// Resolves a slug or status-relative path to an existing doc. A stale path +/// whose doc now lives elsewhere is a Conflict ("changed under you"), not a +/// plain not-found, so agents learn the new location. +pub fn locate_doc(root: &Path, doc_ref: &str) -> Result { + if doc_ref.starts_with("memory/") { + return Err(CoreError::new( + ErrorCode::InvalidInput, + format!("{doc_ref:?} is a memory entry, not a doc; memory has no lifecycle"), + ) + .with_recovery( + "read it with search_memory, overwrite it with write_memory, or remove it with delete_doc", + )); + } + if doc_ref.starts_with("tasks/") { + return Err(CoreError::new( + ErrorCode::InvalidInput, + format!("{doc_ref:?} is a task, not a doc; tasks keep status in frontmatter"), + ) + .with_recovery( + "use set_task_status to move it, update_task to edit it, or delete_doc to remove it", + )); + } + if !doc_ref.contains('/') { + return find_doc_location(root, doc_ref).ok_or_else(|| doc_not_found(doc_ref)); + } + let path = crate::path_guard::safe_join(root, doc_ref)?; + let segments: Vec<&str> = doc_ref.split('/').collect(); + let status = DocStatus::parse(segments[0])?; + let (phase, file_name) = match segments.as_slice() { + [_, file] => (None, *file), + [_, phase, file] => (Some(*phase), *file), + _ => return Err(doc_not_found(doc_ref)), + }; + let Some(slug) = file_name.strip_suffix(".md") else { + return Err(doc_not_found(doc_ref)); + }; + if path.is_file() { + return Ok(location_at(root, status, phase, slug)); + } + match find_doc_location(root, slug) { + Some(current) => Err(CoreError::new( + ErrorCode::Conflict, + format!( + "doc {slug:?} is no longer at {doc_ref:?}; it moved to {:?}", + current.rel_path + ), + ) + .with_recovery(format!("retry with path {:?}", current.rel_path))), + None => Err(doc_not_found(doc_ref)), + } +} + +fn render_doc(doc: &NewDoc<'_>) -> Result { + // Agent-supplied frontmatter wins; otherwise stamp the typed metadata. + if doc.body.trim_start().starts_with("---") { + return Ok(format!("{}\n", doc.body.trim_end())); + } + let fm = serde_yaml::to_string(&Frontmatter { + title: doc.title, + created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + created_by: doc.created_by, + owner: doc.owner, + tags: &doc.tags, + priority: doc.priority, + due: doc.due, + }) + .map_err(|e| CoreError::new(ErrorCode::Io, format!("serialize frontmatter: {e}")))?; + Ok(format!("---\n{fm}---\n\n{}\n", doc.body.trim_end())) +} + +pub(crate) fn enforce_size_limit(rendered_len: usize) -> Result<(), CoreError> { + if rendered_len as u64 > MAX_FILE_BYTES { + return Err(CoreError::new( + ErrorCode::InvalidInput, + format!( + "content exceeds the {} MiB limit", + MAX_FILE_BYTES / (1024 * 1024) + ), + ) + .with_recovery("split the document into smaller docs")); + } + Ok(()) +} + +pub(crate) async fn stage_in_git(root: &Path, rel_paths: &[&str]) { + let root_str = root.to_string_lossy(); + if git::is_git_repo(&root_str).await { + git::git_add(&root_str, rel_paths).await; + } +} + +pub async fn write_doc_core(root: &Path, doc: &NewDoc<'_>) -> Result { + if doc.title.trim().is_empty() { + return Err(CoreError::new(ErrorCode::InvalidInput, "title is required") + .with_recovery("pass a short human-readable title; it becomes the doc's slug")); + } + if let Some(phase) = doc.phase { + validate_phase(phase)?; + } + let rendered = render_doc(doc)?; + enforce_size_limit(rendered.len())?; + let slug = unique_slug(&slugify(doc.title), |s| { + find_doc_location(root, s).is_some() + }); + let target = location_at(root, doc.status, doc.phase, &slug); + std::fs::create_dir_all(target.path.parent().unwrap_or(root))?; + std::fs::write(&target.path, rendered)?; + stage_in_git(root, &[&target.rel_path]).await; + Ok(target.to_written()) +} + +pub(crate) async fn relocate( + root: &Path, + from: &DocLocation, + to: &DocLocation, +) -> Result<(), CoreError> { + if to.path.is_file() { + return Err(CoreError::new( + ErrorCode::Conflict, + format!("a doc already exists at {:?}", to.rel_path), + ) + .with_recovery("archive or rename the existing doc first")); + } + std::fs::create_dir_all(to.path.parent().unwrap_or(root))?; + let root_str = root.to_string_lossy(); + let moved_by_git = git::is_git_repo(&root_str).await + && git::git_mv(&root_str, &from.rel_path, &to.rel_path).await; + if !moved_by_git { + // Untracked file or non-repo: plain rename, then best-effort stage. + std::fs::rename(&from.path, &to.path)?; + stage_in_git(root, &[&from.rel_path, &to.rel_path]).await; + } + Ok(()) +} + +/// The move IS the status change; the phase subfolder is preserved. +pub async fn set_status_core( + root: &Path, + doc_ref: &str, + new_status: DocStatus, +) -> Result { + let from = locate_doc(root, doc_ref)?; + let to = location_at(root, new_status, from.phase.as_deref(), &from.slug); + if from.status == new_status { + return Ok(from.to_written()); + } + relocate(root, &from, &to).await?; + Ok(to.to_written()) +} + +/// Moves a doc into (or out of, with None) a phase subfolder within its status. +pub async fn set_phase_core( + root: &Path, + doc_ref: &str, + phase: Option<&str>, +) -> Result { + if let Some(p) = phase { + validate_phase(p)?; + } + let from = locate_doc(root, doc_ref)?; + let to = location_at(root, from.status, phase, &from.slug); + if from.phase.as_deref() == phase { + return Ok(from.to_written()); + } + relocate(root, &from, &to).await?; + Ok(to.to_written()) +} + +pub async fn archive_doc_core(root: &Path, doc_ref: &str) -> Result { + set_status_core(root, doc_ref, DocStatus::Archived).await +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + fn test_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dr_write_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn git(dir: &Path, args: &[&str]) { + let ok = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .map(|s| s.success()) + .unwrap_or(false); + assert!(ok, "git {args:?} failed"); + } + + #[tokio::test] + async fn write_places_doc_in_status_folder_with_frontmatter() { + let root = test_dir("place"); + let new_doc = NewDoc { + created_by: Some("claude-code"), + phase: Some("discovery"), + tags: vec!["mcp".into()], + ..NewDoc::new("My First Doc", "Body text.", DocStatus::Research) + }; + let doc = write_doc_core(&root, &new_doc).await.unwrap(); + assert_eq!( + doc.rel_path, "research/discovery/my-first-doc.md", + "phase is a subfolder" + ); + + let raw = std::fs::read_to_string(&doc.path).unwrap(); + assert!(raw.starts_with("---\n"), "has frontmatter: {raw}"); + assert!(raw.contains("title: My First Doc")); + assert!(raw.contains("created_at:")); + assert!(raw.contains("created_by: claude-code")); + assert!(raw.contains("- mcp")); + assert!(!raw.contains("status:"), "status must stay folder-only"); + assert!(!raw.contains("phase:"), "phase must stay folder-only"); + assert!(raw.ends_with("Body text.\n")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn set_status_preserves_phase_and_set_phase_moves_within_status() { + let root = test_dir("phase_moves"); + let doc = write_doc_core( + &root, + &NewDoc { + phase: Some("v1"), + ..NewDoc::new("Phased", "x", DocStatus::Research) + }, + ) + .await + .unwrap(); + assert_eq!(doc.rel_path, "research/v1/phased.md"); + + let moved = set_status_core(&root, "phased", DocStatus::Done) + .await + .unwrap(); + assert_eq!(moved.rel_path, "done/v1/phased.md", "phase preserved"); + + let rephased = set_phase_core(&root, "phased", Some("v2")).await.unwrap(); + assert_eq!(rephased.rel_path, "done/v2/phased.md"); + + let cleared = set_phase_core(&root, "phased", None).await.unwrap(); + assert_eq!(cleared.rel_path, "done/phased.md"); + assert!(cleared.path.is_file()); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn stale_path_reports_moved_doc_conflict() { + let root = test_dir("stale"); + let doc = write_doc_core(&root, &NewDoc::new("Wander", "x", DocStatus::Research)) + .await + .unwrap(); + set_status_core(&root, &doc.slug, DocStatus::Done) + .await + .unwrap(); + + let err = set_status_core(&root, "research/wander.md", DocStatus::Archived) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict); + assert!( + err.message.contains("done/wander.md"), + "got: {}", + err.message + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn slug_collisions_get_counter_across_statuses() { + let root = test_dir("collide"); + let a = write_doc_core(&root, &NewDoc::new("Same Title", "a", DocStatus::Research)) + .await + .unwrap(); + let b = write_doc_core(&root, &NewDoc::new("Same Title", "b", DocStatus::Done)) + .await + .unwrap(); + assert_eq!(a.slug, "same-title"); + assert_eq!(b.slug, "same-title-2"); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn move_relocates_between_status_folders() { + let root = test_dir("move"); + let doc = write_doc_core(&root, &NewDoc::new("Movable", "x", DocStatus::Research)) + .await + .unwrap(); + let moved = set_status_core(&root, &doc.slug, DocStatus::Done) + .await + .unwrap(); + assert_eq!(moved.rel_path, "done/movable.md"); + assert!(!doc.path.exists()); + assert!(moved.path.is_file()); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn git_repo_write_stages_and_move_shows_rename() { + if crate::git::git_binary().is_none() { + return; + } + let root = test_dir("gitmv"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "a@b.c"]); + git(&root, &["config", "user.name", "x"]); + + let doc = write_doc_core(&root, &NewDoc::new("Tracked Doc", "x", DocStatus::Research)) + .await + .unwrap(); + let staged = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(&root) + .output() + .unwrap(); + let staged = String::from_utf8_lossy(&staged.stdout).to_string(); + assert!( + staged.contains("A research/tracked-doc.md"), + "write staged the file: {staged}" + ); + + git(&root, &["commit", "-qm", "add doc"]); + set_status_core(&root, &doc.slug, DocStatus::InProgress) + .await + .unwrap(); + let after = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(&root) + .output() + .unwrap(); + let after = String::from_utf8_lossy(&after.stdout).to_string(); + assert!( + after.contains("R research/tracked-doc.md -> in-progress/tracked-doc.md"), + "history reflects git mv: {after}" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn agent_frontmatter_passes_through_unchanged() { + let root = test_dir("ownfm"); + let content = "---\ntitle: Custom\ntags: [x]\n---\n\nBody."; + let doc = write_doc_core( + &root, + &NewDoc::new("Ignored Title", content, DocStatus::Research), + ) + .await + .unwrap(); + let raw = std::fs::read_to_string(&doc.path).unwrap(); + assert_eq!(raw, format!("{content}\n")); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn rejects_empty_title_and_oversize_content() { + let root = test_dir("reject"); + let err = write_doc_core(&root, &NewDoc::new(" ", "x", DocStatus::Research)) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidInput); + + let huge = "x".repeat(MAX_FILE_BYTES as usize + 1); + let err = write_doc_core(&root, &NewDoc::new("Huge", &huge, DocStatus::Research)) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidInput); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn move_unknown_slug_is_doc_not_found() { + let root = test_dir("nomove"); + let err = set_status_core(&root, "ghost", DocStatus::Done) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::DocNotFound); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn status_parses_folder_names_only() { + assert_eq!( + DocStatus::parse("in-progress").unwrap(), + DocStatus::InProgress + ); + let err = DocStatus::parse("wip").unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidInput); + assert!(err.recovery.unwrap().contains("research")); + } +} diff --git a/src-tauri/core/tests/welcome_bundle.rs b/src-tauri/core/tests/welcome_bundle.rs new file mode 100644 index 0000000..fa9976e --- /dev/null +++ b/src-tauri/core/tests/welcome_bundle.rs @@ -0,0 +1,44 @@ +use std::path::PathBuf; + +use docsreader_core::workspace::load_marker; + +fn welcome_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("core crate lives inside src-tauri") + .join("resources/welcome") +} + +#[test] +fn bundled_welcome_marker_is_a_valid_managed_workspace() { + let dir = welcome_dir(); + let marker = load_marker(&dir) + .expect("marker parses with the production loader") + .expect("marker file present"); + assert_eq!(marker.slug, "docsreader-welcome"); + assert_eq!(marker.name.as_deref(), Some("DocsReader welcome")); + let homepage = marker + .homepage + .expect("homepage set for first-run auto-open"); + assert!( + dir.join(&homepage).is_file(), + "homepage {homepage} missing from the bundle" + ); +} + +#[test] +fn welcome_tour_teaches_agent_setup() { + let page = welcome_dir().join("Getting started/Connect your AI agents.md"); + let body = std::fs::read_to_string(&page).expect("agents getting-started page exists"); + for needle in [ + "docsreader-mcp", + "claude mcp add", + "codex mcp add", + "mcpServers", + ] { + assert!( + body.contains(needle), + "agents page lost setup step: {needle}" + ); + } +} diff --git a/src-tauri/src/agents/config.rs b/src-tauri/src/agents/config.rs new file mode 100644 index 0000000..54706c5 --- /dev/null +++ b/src-tauri/src/agents/config.rs @@ -0,0 +1,183 @@ +use std::fs; +use std::path::Path; + +use serde_json::{json, Map, Value}; + +use super::SERVER_NAME; + +pub fn read_json_command(path: &Path, top_key: &str) -> Option { + let text = fs::read_to_string(path).ok()?; + let root: Value = serde_json::from_str(&text).ok()?; + root.get(top_key)? + .get(SERVER_NAME)? + .get("command")? + .as_str() + .map(str::to_owned) +} + +pub fn upsert_json_server( + path: &Path, + top_key: &str, + entry_type: bool, + command: &str, +) -> Result<(), String> { + let mut root = match fs::read_to_string(path) { + Ok(text) => serde_json::from_str::(&text).map_err(|e| { + format!( + "{} is not valid JSON ({e}); fix the file or add this entry under \"{top_key}\" manually: {}", + path.display(), + json_entry(entry_type, command) + ) + })?, + Err(_) => Value::Object(Map::new()), + }; + let obj = root + .as_object_mut() + .ok_or_else(|| format!("{} does not contain a JSON object", path.display()))?; + let servers = obj + .entry(top_key) + .or_insert_with(|| Value::Object(Map::new())); + let servers = servers + .as_object_mut() + .ok_or_else(|| format!("\"{top_key}\" in {} is not a JSON object", path.display()))?; + servers.insert(SERVER_NAME.into(), json_entry(entry_type, command)); + write_atomic(path, &format!("{:#}\n", root)) +} + +fn json_entry(entry_type: bool, command: &str) -> Value { + if entry_type { + json!({ "type": "stdio", "command": command }) + } else { + json!({ "command": command }) + } +} + +pub fn read_toml_command(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + let doc: toml_edit::DocumentMut = text.parse().ok()?; + doc.get("mcp_servers")? + .get(SERVER_NAME)? + .get("command")? + .as_str() + .map(str::to_owned) +} + +pub fn upsert_toml_server(path: &Path, command: &str) -> Result<(), String> { + let text = fs::read_to_string(path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e| { + format!( + "{} is not valid TOML ({e}); fix the file or add this entry manually: [mcp_servers.{SERVER_NAME}] command = \"{command}\"", + path.display() + ) + })?; + let servers = doc + .entry("mcp_servers") + .or_insert(toml_edit::table()) + .as_table_mut() + .ok_or_else(|| format!("mcp_servers in {} is not a table", path.display()))?; + servers.set_implicit(true); + let mut entry = toml_edit::Table::new(); + entry["command"] = toml_edit::value(command); + servers.insert(SERVER_NAME, toml_edit::Item::Table(entry)); + write_atomic(path, &doc.to_string()) +} + +fn write_atomic(path: &Path, content: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("{} has no parent directory", path.display()))?; + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + let tmp = path.with_extension("docsreader-tmp"); + fs::write(&tmp, content).map_err(|e| format!("write {}: {e}", tmp.display()))?; + fs::rename(&tmp, path).map_err(|e| format!("replace {}: {e}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + #[test] + fn json_upsert_creates_file_and_reads_back() { + let dir = tmp(); + let path = dir.path().join("nested/mcp.json"); + upsert_json_server(&path, "mcpServers", false, "/bin/x").unwrap(); + assert_eq!( + read_json_command(&path, "mcpServers").as_deref(), + Some("/bin/x") + ); + let text = fs::read_to_string(&path).unwrap(); + assert!(!text.contains("\"type\"")); + } + + #[test] + fn json_upsert_preserves_other_servers_and_keys() { + let dir = tmp(); + let path = dir.path().join("claude.json"); + fs::write( + &path, + r#"{"zTrailing": 1, "mcpServers": {"other": {"command": "keep"}}, "aLeading": {"x": true}}"#, + ) + .unwrap(); + upsert_json_server(&path, "mcpServers", true, "/bin/x").unwrap(); + let text = fs::read_to_string(&path).unwrap(); + let root: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(root["mcpServers"]["other"]["command"], "keep"); + assert_eq!(root["mcpServers"][SERVER_NAME]["type"], "stdio"); + assert_eq!(root["zTrailing"], 1); + assert_eq!(root["aLeading"]["x"], true); + let z = text.find("zTrailing").unwrap(); + let a = text.find("aLeading").unwrap(); + assert!(z < a, "original key order must be preserved"); + } + + #[test] + fn json_upsert_replaces_existing_docsreader_entry() { + let dir = tmp(); + let path = dir.path().join("mcp.json"); + upsert_json_server(&path, "servers", true, "/old").unwrap(); + upsert_json_server(&path, "servers", true, "/new").unwrap(); + assert_eq!(read_json_command(&path, "servers").as_deref(), Some("/new")); + } + + #[test] + fn json_upsert_rejects_invalid_json_without_touching_file() { + let dir = tmp(); + let path = dir.path().join("mcp.json"); + fs::write(&path, "{ not json").unwrap(); + let err = upsert_json_server(&path, "mcpServers", false, "/bin/x").unwrap_err(); + assert!(err.contains("not valid JSON")); + assert!(err.contains("manually")); + assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json"); + } + + #[test] + fn toml_upsert_preserves_comments_and_existing_servers() { + let dir = tmp(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + "# my codex config\nmodel = \"o4\"\n\n[mcp_servers.other]\ncommand = \"keep\"\n", + ) + .unwrap(); + upsert_toml_server(&path, "/bin/x").unwrap(); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("# my codex config")); + assert!(text.contains("model = \"o4\"")); + assert!(text.contains("[mcp_servers.other]")); + assert!(text.contains(&format!("[mcp_servers.{SERVER_NAME}]"))); + assert!(!text.contains("[mcp_servers]\n"), "no empty parent header"); + assert_eq!(read_toml_command(&path).as_deref(), Some("/bin/x")); + } + + #[test] + fn toml_upsert_creates_missing_file() { + let dir = tmp(); + let path = dir.path().join("config.toml"); + upsert_toml_server(&path, "/bin/x").unwrap(); + assert_eq!(read_toml_command(&path).as_deref(), Some("/bin/x")); + } +} diff --git a/src-tauri/src/agents/mod.rs b/src-tauri/src/agents/mod.rs new file mode 100644 index 0000000..31bf2b1 --- /dev/null +++ b/src-tauri/src/agents/mod.rs @@ -0,0 +1,296 @@ +mod config; + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub const SERVER_NAME: &str = "docsreader"; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ClientId { + ClaudeCode, + Cursor, + Windsurf, + #[serde(rename = "vscode")] + VsCode, + Codex, +} + +pub const CLIENT_IDS: [ClientId; 5] = [ + ClientId::ClaudeCode, + ClientId::Cursor, + ClientId::Windsurf, + ClientId::VsCode, + ClientId::Codex, +]; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ConnectionStatus { + Connected, + Stale, + Disconnected, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentClient { + pub id: ClientId, + pub name: &'static str, + pub detected: bool, + pub status: ConnectionStatus, + pub config_path: String, +} + +enum ConfigFormat { + Json { + top_key: &'static str, + entry_type: bool, + }, + Toml, +} + +struct ClientSpec { + name: &'static str, + detect: Vec, + config: PathBuf, + format: ConfigFormat, +} + +// Config locations and shapes verified against each client's docs +// (Claude Code: code.claude.com/docs/en/mcp; Cursor: cursor.com/docs/context/mcp; +// VS Code: code.visualstudio.com/docs/copilot/customization/mcp-servers; +// Windsurf: docs.windsurf.com/windsurf/cascade/mcp; Codex: developers.openai.com/codex/mcp). +fn spec(id: ClientId, home: &Path) -> ClientSpec { + match id { + ClientId::ClaudeCode => ClientSpec { + name: "Claude Code", + detect: vec![home.join(".claude.json"), home.join(".claude")], + config: home.join(".claude.json"), + format: ConfigFormat::Json { + top_key: "mcpServers", + entry_type: true, + }, + }, + ClientId::Cursor => ClientSpec { + name: "Cursor", + detect: vec![home.join(".cursor")], + config: home.join(".cursor").join("mcp.json"), + format: ConfigFormat::Json { + top_key: "mcpServers", + entry_type: false, + }, + }, + ClientId::Windsurf => ClientSpec { + name: "Windsurf", + detect: vec![home.join(".codeium").join("windsurf")], + config: home + .join(".codeium") + .join("windsurf") + .join("mcp_config.json"), + format: ConfigFormat::Json { + top_key: "mcpServers", + entry_type: false, + }, + }, + ClientId::VsCode => ClientSpec { + name: "VS Code", + detect: vec![vscode_user_dir(home)], + config: vscode_user_dir(home).join("mcp.json"), + format: ConfigFormat::Json { + top_key: "servers", + entry_type: true, + }, + }, + ClientId::Codex => ClientSpec { + name: "Codex", + detect: vec![home.join(".codex")], + config: home.join(".codex").join("config.toml"), + format: ConfigFormat::Toml, + }, + } +} + +// Same folder as settings.json (code.visualstudio.com/docs/configure/settings). +fn vscode_user_dir(home: &Path) -> PathBuf { + if cfg!(target_os = "macos") { + home.join("Library") + .join("Application Support") + .join("Code") + .join("User") + } else if cfg!(windows) { + home.join("AppData") + .join("Roaming") + .join("Code") + .join("User") + } else { + home.join(".config").join("Code").join("User") + } +} + +pub fn sidecar_path() -> Result { + let exe = std::env::current_exe().map_err(|e| format!("resolve current executable: {e}"))?; + let dir = exe + .parent() + .ok_or_else(|| format!("{} has no parent directory", exe.display()))?; + Ok(dir.join(format!("docsreader-mcp{}", std::env::consts::EXE_SUFFIX))) +} + +pub fn detect_clients(home: &Path, sidecar: &Path) -> Vec { + CLIENT_IDS + .iter() + .map(|&id| client_status(id, home, sidecar)) + .collect() +} + +pub fn connect_client(home: &Path, sidecar: &Path, id: ClientId) -> Result { + if !sidecar.exists() { + return Err(format!( + "MCP server binary not found at {}; reinstall DocsReader (dev: cargo build -p docsreader-mcp)", + sidecar.display() + )); + } + let s = spec(id, home); + let command = sidecar.to_string_lossy(); + match s.format { + ConfigFormat::Json { + top_key, + entry_type, + } => config::upsert_json_server(&s.config, top_key, entry_type, &command)?, + ConfigFormat::Toml => config::upsert_toml_server(&s.config, &command)?, + } + Ok(client_status(id, home, sidecar)) +} + +fn client_status(id: ClientId, home: &Path, sidecar: &Path) -> AgentClient { + let s = spec(id, home); + let registered = match s.format { + ConfigFormat::Json { top_key, .. } => config::read_json_command(&s.config, top_key), + ConfigFormat::Toml => config::read_toml_command(&s.config), + }; + let status = match registered { + Some(cmd) if Path::new(&cmd) == sidecar => ConnectionStatus::Connected, + Some(_) => ConnectionStatus::Stale, + None => ConnectionStatus::Disconnected, + }; + AgentClient { + id, + name: s.name, + detected: s.detect.iter().any(|p| p.exists()), + status, + config_path: s.config.to_string_lossy().into_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn home() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + fn find(clients: &[AgentClient], id: ClientId) -> &AgentClient { + clients.iter().find(|c| c.id == id).expect("client present") + } + + #[test] + fn detects_installed_clients_only() { + let dir = home(); + fs::create_dir_all(dir.path().join(".cursor")).unwrap(); + fs::create_dir_all(dir.path().join(".codex")).unwrap(); + let clients = detect_clients(dir.path(), Path::new("/bin/mcp")); + assert_eq!(clients.len(), CLIENT_IDS.len()); + assert!(find(&clients, ClientId::Cursor).detected); + assert!(find(&clients, ClientId::Codex).detected); + assert!(!find(&clients, ClientId::ClaudeCode).detected); + assert!(!find(&clients, ClientId::Windsurf).detected); + } + + #[test] + fn connect_then_status_is_connected_for_each_client() { + let dir = home(); + let sidecar = dir.path().join("docsreader-mcp"); + fs::write(&sidecar, "").unwrap(); + for id in CLIENT_IDS { + let client = connect_client(dir.path(), &sidecar, id).unwrap(); + assert_eq!(client.status, ConnectionStatus::Connected, "{:?}", id); + } + } + + #[test] + fn stale_when_registered_command_points_elsewhere() { + let dir = home(); + let old = dir.path().join("docsreader-mcp"); + fs::write(&old, "").unwrap(); + connect_client(dir.path(), &old, ClientId::Cursor).unwrap(); + let clients = detect_clients(dir.path(), Path::new("/new/docsreader-mcp")); + assert_eq!( + find(&clients, ClientId::Cursor).status, + ConnectionStatus::Stale + ); + } + + #[test] + fn connect_fails_loud_when_sidecar_missing() { + let dir = home(); + let missing = dir.path().join("docsreader-mcp"); + let err = connect_client(dir.path(), &missing, ClientId::Cursor).unwrap_err(); + assert!(err.contains("not found")); + assert!(err.contains("reinstall")); + } + + #[test] + fn claude_code_entry_lands_under_top_level_mcp_servers_with_stdio_type() { + let dir = home(); + let sidecar = dir.path().join("docsreader-mcp"); + fs::write(&sidecar, "").unwrap(); + fs::write( + dir.path().join(".claude.json"), + r#"{"numStartups": 5, "mcpServers": {"other": {"type": "http", "url": "https://x"}}}"#, + ) + .unwrap(); + connect_client(dir.path(), &sidecar, ClientId::ClaudeCode).unwrap(); + let text = fs::read_to_string(dir.path().join(".claude.json")).unwrap(); + let root: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(root["numStartups"], 5); + assert_eq!(root["mcpServers"]["other"]["url"], "https://x"); + assert_eq!(root["mcpServers"][SERVER_NAME]["type"], "stdio"); + assert_eq!( + root["mcpServers"][SERVER_NAME]["command"], + sidecar.to_string_lossy().as_ref() + ); + } + + #[test] + fn vscode_entry_lands_under_servers_key() { + let dir = home(); + let sidecar = dir.path().join("docsreader-mcp"); + fs::write(&sidecar, "").unwrap(); + connect_client(dir.path(), &sidecar, ClientId::VsCode).unwrap(); + let config = spec(ClientId::VsCode, dir.path()).config; + let root: serde_json::Value = + serde_json::from_str(&fs::read_to_string(config).unwrap()).unwrap(); + assert_eq!(root["servers"][SERVER_NAME]["type"], "stdio"); + } + + #[test] + fn client_ids_serialize_kebab_case() { + let ids: Vec = CLIENT_IDS + .iter() + .map(|id| serde_json::to_string(id).unwrap()) + .collect(); + assert_eq!( + ids, + [ + "\"claude-code\"", + "\"cursor\"", + "\"windsurf\"", + "\"vscode\"", + "\"codex\"" + ] + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a09973..1a8f8b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,822 +1,5 @@ -use std::fs::File; -use std::io::Read; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{Duration, Instant}; - -use rayon::prelude::*; -use serde::{Deserialize, Serialize}; -use tauri::path::BaseDirectory; -use tauri::{AppHandle, Emitter, Manager}; -use walkdir::{DirEntry, WalkDir}; - -const PROGRESS_EVENT: &str = "scan-progress"; -const PROGRESS_INTERVAL_MS: u64 = 100; -const MAX_FILES: usize = 50_000; -const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; -const PARTIAL_READ_BYTES: usize = 16 * 1024; - -#[derive(Debug, Serialize, Deserialize)] -pub struct MarkdownFile { - pub path: String, - pub name: String, - #[serde(rename = "relPath")] - pub rel_path: String, - pub title: Option, - pub tags: Vec, - pub modified: Option, - pub size: u64, -} - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct DocsYamlProject { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slug: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tagline: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scope: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub icon: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub homepage: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum DocsYamlNavItem { - Markdown { - title: String, - path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - slug: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - badge: Option, - }, - OpenApi { - title: String, - openapi: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - slug: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - badge: Option, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum DocsYamlNavSection { - Items { - title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - collapsed: Option, - items: Vec, - }, - Folder { - title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - collapsed: Option, - folder: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - sort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - direction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - title_from: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pattern: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - nested: Option, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct DocsYamlCrossLink { - pub project: String, - pub label: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub contexts: Vec, -} - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct DocsYaml { - #[serde(default, rename = "spec_version", skip_serializing_if = "Option::is_none")] - pub spec_version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub project: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub navigation: Vec, - #[serde(default, rename = "cross_links", skip_serializing_if = "Vec::is_empty")] - pub cross_links: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ignore: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub visibility: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ScanResult { - pub root: String, - pub files: Vec, - pub truncated: bool, - #[serde(default, rename = "docsYaml", skip_serializing_if = "Option::is_none")] - pub docs_yaml: Option, - #[serde(default, rename = "docsYamlError", skip_serializing_if = "Option::is_none")] - pub docs_yaml_error: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ScanProgress { - pub root: String, - #[serde(rename = "currentDir")] - pub current_dir: String, - #[serde(rename = "filesFound")] - pub files_found: u64, - #[serde(rename = "dirsVisited")] - pub dirs_visited: u64, - #[serde(rename = "lastFile")] - pub last_file: Option, -} - -fn extract_frontmatter(content: &str) -> Option<&str> { - let trimmed = content.trim_start_matches('\u{feff}').trim_start(); - if !trimmed.starts_with("---") { - return None; - } - let after = &trimmed[3..]; - let nl_idx = after.find('\n')?; - let body = &after[nl_idx + 1..]; - let end = body.find("\n---")?; - Some(&body[..end]) -} - -fn extract_first_heading(content: &str) -> Option { - for line in content.lines().take(120) { - let line = line.trim_start(); - if let Some(rest) = line.strip_prefix("# ") { - return Some(rest.trim().to_string()); - } - } - None -} - -fn parse_meta(content: &str) -> (Option, Vec) { - let mut title: Option = None; - let mut tags: Vec = Vec::new(); - - if let Some(fm) = extract_frontmatter(content) { - if let Ok(value) = serde_yaml::from_str::(fm) { - if let Some(map) = value.as_mapping() { - if let Some(t) = map.get(serde_yaml::Value::String("title".into())) { - if let Some(s) = t.as_str() { - title = Some(s.to_string()); - } - } - if let Some(tg) = map.get(serde_yaml::Value::String("tags".into())) { - if let Some(seq) = tg.as_sequence() { - for v in seq { - if let Some(s) = v.as_str() { - tags.push(s.to_string()); - } - } - } else if let Some(s) = tg.as_str() { - tags.extend( - s.split(',') - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()), - ); - } - } - } - } - } - - if title.is_none() { - title = extract_first_heading(content); - } - - (title, tags) -} - -const SKIP_DIRS: &[&str] = &[ - "node_modules", - "target", - ".git", - ".next", - "dist", - "build", - ".venv", - "venv", - ".cache", - ".turbo", - ".vercel", - ".idea", - ".vscode", - "Library", - "Applications", - "System", - "Pictures", - "Movies", - "Music", - ".Trash", - ".npm", - ".yarn", - ".pnpm-store", - ".cargo", - ".rustup", - ".bun", - ".local", - "Pods", - "build", - ".gradle", - "DerivedData", -]; - -fn is_skipped_dir(name: &str) -> bool { - if name.starts_with('.') { - return true; - } - SKIP_DIRS.contains(&name) -} - -fn is_markdown(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - lower.ends_with(".md") || lower.ends_with(".markdown") || lower.ends_with(".mdx") -} - -fn read_partial(path: &Path) -> std::io::Result { - let mut file = File::open(path)?; - let mut buf = vec![0u8; PARTIAL_READ_BYTES]; - let n = file.read(&mut buf)?; - buf.truncate(n); - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { - std::fs::create_dir_all(dst)?; - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let ty = entry.file_type()?; - let from = entry.path(); - let to = dst.join(entry.file_name()); - if ty.is_dir() { - copy_dir_recursive(&from, &to)?; - } else if ty.is_file() { - std::fs::copy(&from, &to)?; - } - } - Ok(()) -} - -#[tauri::command] -async fn install_welcome_workspace(app: AppHandle) -> Result { - let src = app - .path() - .resolve("resources/welcome", BaseDirectory::Resource) - .map_err(|e| format!("could not resolve welcome resource: {e}"))?; - if !src.exists() { - return Err(format!( - "welcome resource not found at {} - in dev mode, ensure src-tauri/resources/welcome exists; in a packaged build, ensure tauri.conf.json bundle.resources includes resources/welcome/**/*", - src.display() - )); - } - let dst_root = app - .path() - .app_data_dir() - .map_err(|e| format!("could not resolve app data dir: {e}"))?; - let dst = dst_root.join("welcome"); - - if !dst.exists() { - copy_dir_recursive(&src, &dst).map_err(|e| { - format!("copy welcome from {} to {}: {e}", src.display(), dst.display()) - })?; - } - - Ok(dst.to_string_lossy().to_string()) -} - -// ─── Git integration ──────────────────────────────────────────────────────── -// -// We shell out to git for status and HEAD-content lookups. Three reasons -// over a Rust git library: zero binary weight, no runtime dependency on -// libgit2 in the bundle, and the user's installed git always understands -// the user's repo. Workspaces without a `.git` (or without git installed) -// silently skip git decorations. Commands run via tokio::process so the -// async runtime isn't blocked, with a per-call timeout so a hung git -// can't stall other Tauri commands indefinitely. - -#[derive(Debug, Serialize, Deserialize)] -pub struct GitFileStatus { - pub path: String, - pub status: String, - #[serde(default, rename = "originalPath", skip_serializing_if = "Option::is_none")] - pub original_path: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct GitStatus { - pub root: String, - pub files: Vec, -} - -const GIT_TIMEOUT: Duration = Duration::from_secs(30); - -// Find the git executable. PATH on a GUI-launched macOS app is typically -// minimal (no Homebrew dirs), so we probe a small set of common -// install locations as a fallback. Cached after the first lookup. -fn git_binary() -> Option<&'static str> { - static CACHED: OnceLock> = OnceLock::new(); - *CACHED.get_or_init(|| { - const CANDIDATES: &[&str] = &[ - "git", - "/usr/bin/git", - "/opt/homebrew/bin/git", - "/usr/local/bin/git", - ]; - for c in CANDIDATES { - let ok = std::process::Command::new(c) - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if ok { - return Some(*c); - } - } - None - }) -} - -async fn run_git(args: &[&str]) -> Result { - let bin = match git_binary() { - Some(b) => b, - None => return Err("git not found".to_string()), - }; - let fut = tokio::process::Command::new(bin).args(args).output(); - match tokio::time::timeout(GIT_TIMEOUT, fut).await { - Ok(Ok(out)) => Ok(out), - Ok(Err(e)) => Err(format!("git {}: {e}", args.first().copied().unwrap_or(""))), - Err(_) => Err(format!("git {} timed out", args.first().copied().unwrap_or(""))), - } -} - -fn classify_xy(xy: &str) -> &'static str { - let bytes = xy.as_bytes(); - if bytes.len() < 2 { - return "modified"; - } - let x = bytes[0] as char; - let y = bytes[1] as char; - if x == '?' || y == '?' { - return "untracked"; - } - if x == 'U' || y == 'U' || (x == 'D' && y == 'D') || (x == 'A' && y == 'A') { - return "unmerged"; - } - if x == 'A' || y == 'A' { - return "added"; - } - if x == 'D' || y == 'D' { - return "deleted"; - } - if x == 'R' || y == 'R' || x == 'C' || y == 'C' { - return "renamed"; - } - "modified" -} - -#[tauri::command] -async fn git_status(workspace: String) -> Result, String> { - if git_binary().is_none() { - return Ok(None); - } - let toplevel_out = match run_git(&["-C", &workspace, "rev-parse", "--show-toplevel"]).await { - Ok(o) => o, - Err(_) => return Ok(None), - }; - if !toplevel_out.status.success() { - return Ok(None); - } - let toplevel = String::from_utf8_lossy(&toplevel_out.stdout) - .trim() - .to_string(); - - // Workspace must live inside the repo. Compute the prefix so we can - // translate repo-relative paths (what git emits) into - // workspace-relative paths (what the scan uses). - let ws_canonical = std::path::Path::new(&workspace) - .canonicalize() - .unwrap_or_else(|_| std::path::Path::new(&workspace).to_path_buf()); - let tl_canonical = std::path::Path::new(&toplevel) - .canonicalize() - .unwrap_or_else(|_| std::path::Path::new(&toplevel).to_path_buf()); - let prefix = ws_canonical - .strip_prefix(&tl_canonical) - .ok() - .map(|p| p.to_string_lossy().replace('\\', "/")) - .unwrap_or_default(); - - let status_out = run_git(&["-C", &workspace, "status", "--porcelain=v1", "-z"]).await?; - if !status_out.status.success() { - return Err(format!( - "git status: {}", - String::from_utf8_lossy(&status_out.stderr) - )); - } - - let mut files = Vec::new(); - let mut iter = status_out - .stdout - .split(|b| *b == 0) - .filter(|t| !t.is_empty()) - .peekable(); - while let Some(tok) = iter.next() { - let s = match std::str::from_utf8(tok) { - Ok(s) => s, - Err(_) => continue, - }; - if s.len() < 4 { - continue; - } - let xy = &s[..2]; - let path = s[3..].to_string(); - let status = classify_xy(xy); - let is_rename = xy.contains('R') || xy.contains('C'); - - let original_path = if is_rename { - iter.next() - .and_then(|t| std::str::from_utf8(t).ok().map(|s| s.to_string())) - } else { - None - }; - - let final_path = if prefix.is_empty() { - path.clone() - } else if path == prefix { - String::new() - } else if let Some(rest) = path.strip_prefix(&format!("{}/", prefix)) { - rest.to_string() - } else { - continue; - }; - - files.push(GitFileStatus { - path: final_path, - status: status.to_string(), - original_path, - }); - } - - Ok(Some(GitStatus { - root: toplevel, - files, - })) -} - -#[tauri::command] -async fn git_show_head(workspace: String, path: String) -> Result, String> { - if git_binary().is_none() { - return Ok(None); - } - let out = run_git(&["-C", &workspace, "show", &format!("HEAD:./{}", path)]).await?; - if !out.status.success() { - // Common case: file is untracked / new (no HEAD revision). Don't - // treat as an error - the caller renders an "all added" diff. - let stderr = String::from_utf8_lossy(&out.stderr); - if stderr.contains("exists on disk, but not in") - || stderr.contains("does not exist") - || stderr.contains("path does not exist") - || stderr.contains("bad revision") - { - return Ok(None); - } - return Err(format!("git show: {}", stderr)); - } - Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) -} - -fn load_docs_yaml(root: &Path) -> (Option, Option) { - for name in [".docs.yaml", "docs.yaml"] { - let p = root.join(name); - if !p.exists() { - continue; - } - return match std::fs::read_to_string(&p) { - Ok(s) => match serde_yaml::from_str::(&s) { - Ok(parsed) => (Some(parsed), None), - Err(e) => (None, Some(format!("{}: {}", name, e))), - }, - Err(e) => (None, Some(format!("{}: {}", name, e))), - }; - } - (None, None) -} - -#[tauri::command] -async fn scan_markdown(app: AppHandle, path: String) -> Result { - let app_clone = app.clone(); - let path_for_task = path.clone(); - tauri::async_runtime::spawn_blocking(move || run_scan(app_clone, path_for_task)) - .await - .map_err(|e| format!("scan task panicked: {}", e))? -} - -fn run_scan(app: AppHandle, path: String) -> Result { - let root_path = Path::new(&path); - if !root_path.exists() { - return Err(format!("Path does not exist: {}", path)); - } - - let dirs_visited = Arc::new(AtomicU64::new(0)); - let last_emit = Arc::new(Mutex::new(Instant::now())); - - let mut entries: Vec = Vec::new(); - let mut truncated = false; - - let walker = WalkDir::new(root_path) - .follow_links(false) - .into_iter() - .filter_entry(|e| { - if e.depth() == 0 { - return true; - } - let name = e.file_name().to_string_lossy(); - if e.file_type().is_dir() { - !is_skipped_dir(&name) - } else { - !name.starts_with('.') - } - }); - - for entry in walker.filter_map(|e| e.ok()) { - if entry.file_type().is_dir() { - dirs_visited.fetch_add(1, Ordering::Relaxed); - maybe_emit_walk_progress( - &app, - &path, - entry.path(), - root_path, - &dirs_visited, - 0, - None, - &last_emit, - ); - continue; - } - - if !entry.file_type().is_file() { - continue; - } - - let name = entry.file_name().to_string_lossy().to_string(); - if !is_markdown(&name) { - continue; - } - - if let Ok(meta) = entry.metadata() { - if meta.len() > MAX_FILE_BYTES { - continue; - } - } - - if entries.len() >= MAX_FILES { - truncated = true; - break; - } - entries.push(entry); - } - - let total_to_read = entries.len() as u64; - let files_processed = Arc::new(AtomicU64::new(0)); - - let mut files: Vec = entries - .par_iter() - .filter_map(|entry| { - let metadata = entry.metadata().ok()?; - let size = metadata.len(); - let modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()); - - let content = read_partial(entry.path()).ok()?; - let (title, tags) = parse_meta(&content); - - let rel_path = entry - .path() - .strip_prefix(root_path) - .unwrap_or(entry.path()) - .to_string_lossy() - .to_string(); - - let count = files_processed.fetch_add(1, Ordering::Relaxed) + 1; - maybe_emit_read_progress( - &app, - &path, - count, - total_to_read, - Some(rel_path.clone()), - &last_emit, - ); - - Some(MarkdownFile { - path: entry.path().to_string_lossy().to_string(), - name: entry - .path() - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(), - rel_path, - title, - tags, - modified, - size, - }) - }) - .collect(); - - files.sort_by(|a, b| a.rel_path.to_lowercase().cmp(&b.rel_path.to_lowercase())); - - let _ = app.emit( - PROGRESS_EVENT, - ScanProgress { - root: path.clone(), - current_dir: ".".to_string(), - files_found: files.len() as u64, - dirs_visited: dirs_visited.load(Ordering::Relaxed), - last_file: None, - }, - ); - - let (docs_yaml, docs_yaml_error) = load_docs_yaml(root_path); - - Ok(ScanResult { - root: root_path.to_string_lossy().to_string(), - files, - truncated, - docs_yaml, - docs_yaml_error, - }) -} - -fn maybe_emit_walk_progress( - app: &AppHandle, - root: &str, - current: &Path, - root_path: &Path, - dirs_visited: &AtomicU64, - files_found: u64, - last_file: Option, - last_emit: &Mutex, -) { - { - let mut guard = match last_emit.lock() { - Ok(g) => g, - Err(_) => return, - }; - if guard.elapsed() < Duration::from_millis(PROGRESS_INTERVAL_MS) { - return; - } - *guard = Instant::now(); - } - let rel = current - .strip_prefix(root_path) - .unwrap_or(current) - .to_string_lossy() - .to_string(); - let _ = app.emit( - PROGRESS_EVENT, - ScanProgress { - root: root.to_string(), - current_dir: if rel.is_empty() { ".".into() } else { rel }, - files_found, - dirs_visited: dirs_visited.load(Ordering::Relaxed), - last_file, - }, - ); -} - -fn maybe_emit_read_progress( - app: &AppHandle, - root: &str, - files_found: u64, - total: u64, - last_file: Option, - last_emit: &Mutex, -) { - { - let mut guard = match last_emit.lock() { - Ok(g) => g, - Err(_) => return, - }; - if guard.elapsed() < Duration::from_millis(PROGRESS_INTERVAL_MS) { - return; - } - *guard = Instant::now(); - } - let _ = app.emit( - PROGRESS_EVENT, - ScanProgress { - root: root.to_string(), - current_dir: format!("reading {} of {}", files_found, total), - files_found, - dirs_visited: 0, - last_file, - }, - ); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_v01_manifest_with_mixed_sections() { - let yaml = r##" -spec_version: "0.1" -project: - slug: voice - name: Vinfra Voice - tagline: Carrier-grade VoIP for MENA - scope: L1 - icon: phone - homepage: docs/spec/architecture.md -navigation: - - title: Start here - items: - - title: Architecture overview - path: docs/spec/architecture.md - - title: Operating contract - path: docs/CONTRACT.md - badge: live - - title: Architecture decisions - folder: docs/adr/ - sort: filename - title_from: heading - - title: Build curriculum - folder: docs/phases/ - nested: true -cross_links: - - project: agent - label: "Need voice AI?" - description: Vinfra Agent runs alongside. -ignore: - - docs/archived/** - - "**/*.draft.md" -visibility: internal -"##; - let parsed: DocsYaml = serde_yaml::from_str(yaml).expect("should parse"); - let project = parsed.project.expect("project required"); - assert_eq!(project.slug.as_deref(), Some("voice")); - assert_eq!(project.name.as_deref(), Some("Vinfra Voice")); - assert_eq!(parsed.navigation.len(), 3); - assert_eq!(parsed.ignore.len(), 2); - assert_eq!(parsed.cross_links.len(), 1); - assert_eq!(parsed.cross_links[0].project, "agent"); - assert_eq!(parsed.cross_links[0].label, "Need voice AI?"); - - let (mut items, mut folder) = (false, false); - for s in &parsed.navigation { - match s { - DocsYamlNavSection::Items { .. } => items = true, - DocsYamlNavSection::Folder { .. } => folder = true, - } - } - assert!(items && folder, "both section kinds match"); - } - - #[test] - fn ignores_unknown_top_level_fields() { - let yaml = r##" -spec_version: "0.1" -project: - slug: x - name: X -navigation: - - title: T - folder: docs/ -api_reference: - openapi: api.yaml -versions: - current: a - supported: [a] -theme: - primary_color: "#000" -metadata: - team: t -"##; - let parsed: DocsYaml = serde_yaml::from_str(yaml).expect("should parse with extras"); - assert_eq!(parsed.navigation.len(), 1); - } -} +mod agents; +mod tauri_api; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -828,10 +11,13 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .invoke_handler(tauri::generate_handler![ - scan_markdown, - install_welcome_workspace, - git_status, - git_show_head + tauri_api::scan_markdown, + tauri_api::convert_workspace, + tauri_api::detect_agent_clients, + tauri_api::connect_agent_client, + tauri_api::install_welcome_workspace, + tauri_api::git_status, + tauri_api::git_show_head ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/tauri_api/mod.rs b/src-tauri/src/tauri_api/mod.rs new file mode 100644 index 0000000..49eb81e --- /dev/null +++ b/src-tauri/src/tauri_api/mod.rs @@ -0,0 +1,114 @@ +use std::path::Path; + +use tauri::path::BaseDirectory; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::agents::{self, AgentClient, ClientId}; +use docsreader_core::git::{git_show_head_core, git_status_core, GitStatus}; +use docsreader_core::scan::{run_scan, ScanProgress, ScanProgressSink, ScanResult}; +use docsreader_core::workspace::init::{convert_workspace_core, InitializedWorkspace}; +use docsreader_core::workspace::registry::default_registry_path; + +const PROGRESS_EVENT: &str = "scan-progress"; + +struct TauriProgressSink { + app: AppHandle, +} + +impl ScanProgressSink for TauriProgressSink { + fn emit(&self, progress: &ScanProgress) { + let _ = self.app.emit(PROGRESS_EVENT, progress.clone()); + } +} + +#[tauri::command] +pub async fn scan_markdown(app: AppHandle, path: String) -> Result { + let sink = TauriProgressSink { app }; + let path_for_task = path.clone(); + tauri::async_runtime::spawn_blocking(move || run_scan(&sink, path_for_task)) + .await + .map_err(|e| format!("scan task panicked: {}", e))? +} + +#[tauri::command] +pub async fn convert_workspace( + app: AppHandle, + path: String, +) -> Result { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + let registry = default_registry_path(&home); + tauri::async_runtime::spawn_blocking(move || { + convert_workspace_core(Path::new(&path), ®istry).map_err(|e| e.message) + }) + .await + .map_err(|e| format!("convert task panicked: {e}"))? +} + +#[tauri::command] +pub fn detect_agent_clients(app: AppHandle) -> Result, String> { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + Ok(agents::detect_clients(&home, &agents::sidecar_path()?)) +} + +#[tauri::command] +pub fn connect_agent_client(app: AppHandle, id: ClientId) -> Result { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + agents::connect_client(&home, &agents::sidecar_path()?, id) +} + +#[tauri::command] +pub async fn git_status(workspace: String) -> Result, String> { + git_status_core(workspace).await +} + +#[tauri::command] +pub async fn git_show_head(workspace: String, path: String) -> Result, String> { + git_show_head_core(workspace, path).await +} + +#[tauri::command] +pub async fn install_welcome_workspace(app: AppHandle) -> Result { + let src = app + .path() + .resolve("resources/welcome", BaseDirectory::Resource) + .map_err(|e| format!("could not resolve welcome resource: {e}"))?; + if !src.exists() { + return Err(format!( + "welcome resource not found at {} - in dev mode, ensure src-tauri/resources/welcome exists; in a packaged build, ensure tauri.conf.json bundle.resources includes resources/welcome/**/*", + src.display() + )); + } + let dst_root = app + .path() + .app_data_dir() + .map_err(|e| format!("could not resolve app data dir: {e}"))?; + let dst = dst_root.join("welcome"); + + if !dst.exists() { + copy_dir_recursive(&src, &dst).map_err(|e| { + format!( + "copy welcome from {} to {}: {e}", + src.display(), + dst.display() + ) + })?; + } + + Ok(dst.to_string_lossy().to_string()) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_recursive(&from, &to)?; + } else if ty.is_file() { + std::fs::copy(&from, &to)?; + } + } + Ok(()) +} From 797524be31aa7c4dd01fbb8641ef4025514627a4 Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Fri, 3 Jul 2026 12:50:06 +0800 Subject: [PATCH 02/10] feat(mcp): add docsreader-mcp stdio server bundled as a sidecar MCP spec 2025-11-25 server over stdio: doc/memory/task tools with recovery-bearing errors, every doc exposed as a resource, a self-describing onboarding resource, start-task and record-decision prompts, workspace auto-detection via CLAUDE_PROJECT_DIR with walk-up, and an elicitation picker for unknown slugs. The sidecar overlay config stages the binary for bundle.externalBin and owns createUpdaterArtifacts so plain local builds never need the staged binary or the signing key. --- scripts/build-sidecar.ts | 57 +++ src-tauri/mcp/Cargo.toml | 18 + src-tauri/mcp/src/main.rs | 23 + src-tauri/mcp/src/server/elicit.rs | 71 ++++ src-tauri/mcp/src/server/memory_tools.rs | 142 +++++++ src-tauri/mcp/src/server/mod.rs | 188 +++++++++ src-tauri/mcp/src/server/onboarding.md | 64 +++ src-tauri/mcp/src/server/prompts.rs | 84 ++++ src-tauri/mcp/src/server/read_tools.rs | 207 +++++++++ src-tauri/mcp/src/server/resources.rs | 192 +++++++++ src-tauri/mcp/src/server/task_tools.rs | 226 ++++++++++ src-tauri/mcp/src/server/workspace_tools.rs | 121 ++++++ src-tauri/mcp/src/server/write_tools.rs | 338 +++++++++++++++ src-tauri/mcp/tests/stdio.rs | 443 ++++++++++++++++++++ src-tauri/tauri.conf.json | 3 +- src-tauri/tauri.sidecar.conf.json | 10 + 16 files changed, 2185 insertions(+), 2 deletions(-) create mode 100644 scripts/build-sidecar.ts create mode 100644 src-tauri/mcp/Cargo.toml create mode 100644 src-tauri/mcp/src/main.rs create mode 100644 src-tauri/mcp/src/server/elicit.rs create mode 100644 src-tauri/mcp/src/server/memory_tools.rs create mode 100644 src-tauri/mcp/src/server/mod.rs create mode 100644 src-tauri/mcp/src/server/onboarding.md create mode 100644 src-tauri/mcp/src/server/prompts.rs create mode 100644 src-tauri/mcp/src/server/read_tools.rs create mode 100644 src-tauri/mcp/src/server/resources.rs create mode 100644 src-tauri/mcp/src/server/task_tools.rs create mode 100644 src-tauri/mcp/src/server/workspace_tools.rs create mode 100644 src-tauri/mcp/src/server/write_tools.rs create mode 100644 src-tauri/mcp/tests/stdio.rs create mode 100644 src-tauri/tauri.sidecar.conf.json diff --git a/scripts/build-sidecar.ts b/scripts/build-sidecar.ts new file mode 100644 index 0000000..18a661e --- /dev/null +++ b/scripts/build-sidecar.ts @@ -0,0 +1,57 @@ +// Builds the docsreader-mcp sidecar and places it where the Tauri bundler +// expects external binaries: src-tauri/binaries/docsreader-mcp-[.exe]. +// The triple comes from TAURI_ENV_TARGET_TRIPLE when run as a Tauri hook; +// "universal-apple-darwin" builds both Apple arches and merges them via lipo. +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dir, ".."); +const srcTauri = path.join(root, "src-tauri"); +const outDir = path.join(srcTauri, "binaries"); + +function run(cmd: string, args: string[]): string { + const res = spawnSync(cmd, args, { cwd: root, stdio: ["ignore", "pipe", "inherit"] }); + if (res.status !== 0) { + throw new Error(`${cmd} ${args.join(" ")} exited with ${res.status}`); + } + return res.stdout.toString(); +} + +function hostTriple(): string { + const line = run("rustc", ["-vV"]) + .split("\n") + .find((l) => l.startsWith("host: ")); + if (!line) throw new Error("could not determine host triple from rustc -vV"); + return line.slice("host: ".length).trim(); +} + +function buildFor(target: string): string { + run("cargo", [ + "build", + "--release", + "--manifest-path", + path.join(srcTauri, "Cargo.toml"), + "-p", + "docsreader-mcp", + "--target", + target, + ]); + const ext = target.includes("windows") ? ".exe" : ""; + return path.join(srcTauri, "target", target, "release", `docsreader-mcp${ext}`); +} + +const triple = process.env.TAURI_ENV_TARGET_TRIPLE ?? hostTriple(); +mkdirSync(outDir, { recursive: true }); + +if (triple === "universal-apple-darwin") { + const slices = ["aarch64-apple-darwin", "x86_64-apple-darwin"].map(buildFor); + const dest = path.join(outDir, `docsreader-mcp-${triple}`); + run("lipo", ["-create", ...slices, "-output", dest]); + console.log(`sidecar: ${dest} (universal)`); +} else { + const ext = triple.includes("windows") ? ".exe" : ""; + const dest = path.join(outDir, `docsreader-mcp-${triple}${ext}`); + copyFileSync(buildFor(triple), dest); + console.log(`sidecar: ${dest}`); +} diff --git a/src-tauri/mcp/Cargo.toml b/src-tauri/mcp/Cargo.toml new file mode 100644 index 0000000..1391f4c --- /dev/null +++ b/src-tauri/mcp/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "docsreader-mcp" +version = "0.1.0" +edition = "2024" + +[dependencies] +chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } +docsreader-core = { version = "0.6.0", path = "../core", features = ["schemars"] } +rmcp = { version = "2.0.0", features = ["server", "macros", "transport-io", "elicitation"] } +schemars = "1.2.1" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", default-features = false, features = ["env-filter", "fmt", "std", "ansi"] } + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/src-tauri/mcp/src/main.rs b/src-tauri/mcp/src/main.rs new file mode 100644 index 0000000..c925b85 --- /dev/null +++ b/src-tauri/mcp/src/main.rs @@ -0,0 +1,23 @@ +mod server; + +use rmcp::ServiceExt; +use server::DocsServer; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // stdout carries JSON-RPC only; logs go to stderr per the MCP stdio + // transport spec (clients may capture or ignore them). Level via RUST_LOG. + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + tracing::info!( + version = env!("CARGO_PKG_VERSION"), + "docsreader-mcp starting" + ); + let running = DocsServer.serve(rmcp::transport::stdio()).await?; + running.waiting().await?; + Ok(()) +} diff --git a/src-tauri/mcp/src/server/elicit.rs b/src-tauri/mcp/src/server/elicit.rs new file mode 100644 index 0000000..4565e0a --- /dev/null +++ b/src-tauri/mcp/src/server/elicit.rs @@ -0,0 +1,71 @@ +use docsreader_core::error::{CoreError, ErrorCode}; +use docsreader_core::workspace::resolve::ResolvedWorkspace; +use rmcp::model::{ElicitRequestParams, ElicitationAction, ElicitationSchema, EnumSchema}; +use rmcp::service::ElicitationMode; +use rmcp::{Peer, RoleServer}; + +use super::{known_slugs, resolve}; + +/// Unknown-slug recovery for interactive clients: offer a form-elicitation +/// enum picker over the workspaces that would resolve (2025-11-25 enum + +/// default support). Headless clients and declines keep the recovery-bearing +/// error, so the non-interactive `workspace` argument path always works. +/// Only workspace slugs cross the wire - never sensitive data. +pub(crate) async fn resolve_or_pick( + peer: &Peer, + explicit: Option<&str>, +) -> Result { + let err = match resolve(explicit) { + Ok(ws) => return Ok(ws), + Err(err) => err, + }; + if err.code != ErrorCode::WorkspaceNotFound { + return Err(err); + } + match pick_workspace(peer, explicit.unwrap_or_default()).await { + Some(slug) => resolve(Some(&slug)), + None => Err(err), + } +} + +async fn pick_workspace(peer: &Peer, requested: &str) -> Option { + if !peer + .supported_elicitation_modes() + .contains(&ElicitationMode::Form) + { + return None; + } + let choices = known_slugs().ok()?; + if choices.is_empty() { + return None; + } + let params = ElicitRequestParams::FormElicitationParams { + meta: None, + message: format!("Workspace {requested:?} was not found. Pick the workspace to use."), + requested_schema: workspace_schema(choices)?, + }; + let result = peer.create_elicitation(params).await.ok()?; + if result.action != ElicitationAction::Accept { + return None; + } + result + .content? + .get("workspace")? + .as_str() + .map(str::to_owned) +} + +fn workspace_schema(choices: Vec) -> Option { + let default = resolve(None) + .ok() + .map(|ws| ws.slug) + .filter(|slug| choices.contains(slug)); + let mut choices = EnumSchema::builder(choices); + if let Some(default) = default { + choices = choices.with_default(default).ok()?; + } + ElicitationSchema::builder() + .required_enum_schema("workspace", choices.build()) + .build() + .ok() +} diff --git a/src-tauri/mcp/src/server/memory_tools.rs b/src-tauri/mcp/src/server/memory_tools.rs new file mode 100644 index 0000000..28a8f48 --- /dev/null +++ b/src-tauri/mcp/src/server/memory_tools.rs @@ -0,0 +1,142 @@ +use docsreader_core::error::CoreError; +use docsreader_core::memory::{MemoryEntry, MemoryHit, search_memory_core, write_memory_core}; +use docsreader_core::workspace::resolve::ResolvedWorkspace; +use rmcp::handler::server::wrapper::{Json, Parameters}; +use rmcp::model::CallToolResult; +use rmcp::{Peer, RoleServer, tool, tool_router}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{ + DocsServer, TRUNCATION_GUIDANCE, client_name, doc_uri, ensure_workspace_exists, error_result, + resolve_or_pick, take_within_budget, +}; + +#[derive(Deserialize, JsonSchema)] +pub struct WriteMemoryParams { + /// Short topic the memory is about, e.g. "user-preferences" or + /// "auth stack". One entry per topic; writing the same topic overwrites. + pub topic: String, + /// The fact(s) to remember, as markdown. Replaces any previous content + /// for this topic wholesale. + pub content: String, + /// Topic tags. + pub tags: Option>, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SearchMemoryParams { + /// Search term matched against topic, tags, and content. Omit to list + /// every entry, newest first. + pub query: Option, + /// Filter by tag. + pub tag: Option, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MemoryWriteResult { + pub ok: bool, + pub workspace: ResolvedWorkspace, + #[serde(flatten)] + pub entry: MemoryEntry, + /// Resource URI for this entry (docsreader:///memory/.md). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MemoryHitEntry { + #[serde(flatten)] + pub hit: MemoryHit, + /// Resource URI for this entry (docsreader:///memory/.md). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SearchMemoryResult { + pub workspace: ResolvedWorkspace, + pub memories: Vec, + pub truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance: Option, +} + +#[tool_router(router = memory_tool_router, vis = "pub(crate)")] +impl DocsServer { + #[tool( + description = "Save a short topic-addressed memory (a fact, preference, or decision) for future sessions. One entry per topic: writing an existing topic overwrites its content wholesale, so include everything still worth remembering. Memories have no lifecycle status.", + annotations(idempotent_hint = true) + )] + async fn write_memory( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let agent = client_name(&peer); + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + ensure_workspace_exists(&ws)?; + let entry = write_memory_core( + &ws.root, + &p.topic, + &p.content, + &p.tags.clone().unwrap_or_default(), + agent.as_deref(), + ) + .await?; + Ok::<_, CoreError>((ws, entry)) + } + .await; + result + .map(|(ws, entry)| { + Json(MemoryWriteResult { + ok: true, + uri: doc_uri(&ws.slug, &entry.rel_path), + workspace: ws, + entry, + }) + }) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Recall memories: ranked matches against topic, tags, and content, each with its full content. Omit the query to list every entry, newest first. Check here for prior context before re-deriving facts.", + annotations(read_only_hint = true) + )] + async fn search_memory( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let hits = if ws.root.is_dir() { + search_memory_core(&ws.root, p.query.as_deref(), p.tag.as_deref())? + } else { + Vec::new() + }; + let entries: Vec = hits + .into_iter() + .map(|hit| MemoryHitEntry { + uri: doc_uri(&ws.slug, &hit.rel_path), + hit, + }) + .collect(); + let (memories, truncated) = take_within_budget(entries); + Ok::<_, CoreError>(SearchMemoryResult { + workspace: ws, + memories, + truncated, + guidance: truncated.then(|| TRUNCATION_GUIDANCE.to_string()), + }) + } + .await; + result.map(Json).map_err(|e| error_result(&e)) + } +} diff --git a/src-tauri/mcp/src/server/mod.rs b/src-tauri/mcp/src/server/mod.rs new file mode 100644 index 0000000..dcc8585 --- /dev/null +++ b/src-tauri/mcp/src/server/mod.rs @@ -0,0 +1,188 @@ +mod elicit; +mod memory_tools; +mod prompts; +mod read_tools; +mod resources; +mod task_tools; +mod workspace_tools; +mod write_tools; + +use std::path::PathBuf; + +use docsreader_core::error::{CoreError, ErrorCode}; +use docsreader_core::workspace::WorkspaceScope; +use docsreader_core::workspace::init::init_workspace_core; +use docsreader_core::workspace::registry::{default_registry_path, load_registry}; +use docsreader_core::workspace::resolve::{ResolvedWorkspace, available_slugs, resolve_workspace}; +use docsreader_core::write::DocStatus; +use rmcp::model::{ + CallToolResult, ContentBlock, Implementation, ListResourceTemplatesResult, ListResourcesResult, + PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, Resource, + ServerCapabilities, ServerInfo, +}; +use rmcp::service::RequestContext; +use rmcp::{ErrorData, Peer, RoleServer, ServerHandler, prompt_handler, tool_handler}; + +pub(crate) use elicit::resolve_or_pick; + +pub struct DocsServer; + +impl DocsServer { + fn combined_router() -> rmcp::handler::server::router::tool::ToolRouter { + Self::workspace_tool_router() + + Self::write_tool_router() + + Self::read_tool_router() + + Self::memory_tool_router() + + Self::task_tool_router() + } +} + +const INSTRUCTIONS: &str = "DocsReader serves markdown docs, memory, and tasks from local workspaces. Docs live in status folders (research/in-progress/done/archived), optionally grouped by phase subfolders. Read the docsreader://onboarding resource first for the full model. Start with list_workspaces; create docs with write_doc; find them with list_docs/search_docs; read with read_doc; edit with update_doc; move through the lifecycle with set_status/set_phase/archive. Save short topic-addressed facts with write_memory; recall them with search_memory. Track work with write_task/list_tasks/set_task_status/update_task (Backlog.md-shaped files in tasks/)."; + +#[tool_handler(router = Self::combined_router())] +#[prompt_handler(router = Self::prompt_router())] +impl ServerHandler for DocsServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .enable_prompts() + .build(), + ) + .with_server_info(Implementation::new("docsreader", env!("CARGO_PKG_VERSION"))) + .with_instructions(INSTRUCTIONS) + } + + async fn list_resources( + &self, + request: Option, + _context: RequestContext, + ) -> Result { + resources::list_resources_page(request.and_then(|r| r.cursor)) + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(resources::list_templates()) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + resources::read_resource_at(&request.uri) + } +} + +pub(crate) fn success_json(value: serde_json::Value) -> CallToolResult { + CallToolResult::success(vec![ContentBlock::text(value.to_string())]) +} + +pub(crate) fn error_result(err: &CoreError) -> CallToolResult { + tracing::debug!(code = ?err.code, message = %err.message, "tool error"); + let payload = serde_json::json!({ "error": err }); + CallToolResult::error(vec![ContentBlock::text(payload.to_string())]) +} + +pub(crate) fn home_dir() -> Result { + std::env::home_dir().ok_or_else(|| { + CoreError::new(ErrorCode::Io, "cannot determine home directory") + .with_recovery("set the HOME environment variable for the MCP server process") + }) +} + +pub(crate) fn client_name(peer: &Peer) -> Option { + peer.peer_info().map(|info| info.client_info.name.clone()) +} + +pub(crate) fn resolve(explicit_slug: Option<&str>) -> Result { + let home = home_dir()?; + let registry = load_registry(&default_registry_path(&home))?; + let cwd = std::env::current_dir()?; + resolve_workspace(explicit_slug, &project_dir_hint(), &cwd, &home, ®istry) +} + +/// Claude Code sets CLAUDE_PROJECT_DIR to the project root in the spawned +/// server's environment (code.claude.com/docs/en/mcp). MCP Roots would carry +/// the same signal but is deprecated by SEP-2577, so the env var plus cwd +/// walk-up is the whole auto-detect story. +fn project_dir_hint() -> Vec { + std::env::var_os("CLAUDE_PROJECT_DIR") + .map(PathBuf::from) + .into_iter() + .collect() +} + +pub(crate) fn known_slugs() -> Result, CoreError> { + let home = home_dir()?; + let registry = load_registry(&default_registry_path(&home))?; + let ambient = resolve(None).ok(); + Ok(available_slugs(®istry, ambient.as_ref())) +} + +/// The default user workspace is create-on-first-use: writing to it before +/// init_workspace must succeed, so agents never hit a setup wall. +pub(crate) fn ensure_workspace_exists(ws: &ResolvedWorkspace) -> Result<(), CoreError> { + if ws.root.is_dir() { + return Ok(()); + } + if ws.scope != WorkspaceScope::User { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + format!("workspace directory {} is missing", ws.root.display()), + ) + .with_recovery( + "call list_workspaces to see valid slugs, or init_workspace to create one", + )); + } + let home = home_dir()?; + init_workspace_core( + &ws.root, + Some(&ws.slug), + None, + WorkspaceScope::User, + &default_registry_path(&home), + )?; + Ok(()) +} + +pub(crate) fn doc_uri(ws_slug: &str, rel_path: &str) -> String { + format!("docsreader://{ws_slug}/{rel_path}") +} + +pub(crate) fn doc_resource_link(ws_slug: &str, rel_path: &str, title: &str) -> ContentBlock { + let resource = Resource::new(doc_uri(ws_slug, rel_path), rel_path) + .with_title(title) + .with_mime_type("text/markdown"); + ContentBlock::resource_link(resource) +} + +pub(crate) fn parse_status(value: Option<&str>) -> Result, CoreError> { + value.map(DocStatus::parse).transpose() +} + +pub(crate) const TRUNCATION_GUIDANCE: &str = + "response hit the size budget; narrow with status/phase/tag filters or a more specific query"; + +/// Keeps the serialized entry list under the response budget; the guidance +/// string tells the agent how to narrow instead of silently dropping items. +pub(crate) fn take_within_budget(items: Vec) -> (Vec, bool) { + let mut used = 0usize; + let mut kept = Vec::new(); + let mut truncated = false; + for item in items { + let cost = serde_json::to_string(&item).map(|s| s.len()).unwrap_or(0); + if used + cost > docsreader_core::read::RESPONSE_BUDGET_CHARS { + truncated = true; + break; + } + used += cost; + kept.push(item); + } + (kept, truncated) +} diff --git a/src-tauri/mcp/src/server/onboarding.md b/src-tauri/mcp/src/server/onboarding.md new file mode 100644 index 0000000..e4d03ad --- /dev/null +++ b/src-tauri/mcp/src/server/onboarding.md @@ -0,0 +1,64 @@ +# DocsReader agent onboarding + +DocsReader is a local markdown store you write to over MCP while humans read the +same files in the DocsReader app. Prefer these tools over raw file writes: they +handle slugs, frontmatter, collisions, lifecycle moves, and git staging. + +## Model + +- A workspace is a folder of markdown docs. Default user workspace: `~/notes` + (created on first write). A project can opt in with its own `/notes` + via `init_workspace`; when one exists it takes precedence. +- Docs live in the folder matching their lifecycle status: + `research/`, `in-progress/`, `done/`, `archived/`. The folder IS the status. +- Optional phase subfolders group work inside a status, e.g. + `research/v2-launch/plan.md`. The folder IS the phase. +- Filenames are slugs generated from titles. Frontmatter carries title, tags, + owner, created_at, created_by - never status or phase. + +## Workflow + +1. `list_workspaces` shows what exists; `init_workspace` creates one. +2. `write_doc {title, body, status}` creates a doc and returns its URI. +3. `list_docs` / `search_docs` find docs; results are ranked and budgeted. +4. `read_doc {path}` reads one: concise (snippet) by default, `detailed` for + the full body. +5. `update_doc {path, old_str, new_str}` edits in place by exact string + replacement; `old_str` must appear exactly once. +6. `set_status` / `set_phase` / `archive` move docs through the lifecycle. + The move is the status change; nothing else to update. + +## Memory + +- Short, topic-addressed facts live in `memory/` ("user prefers tabs", + "project uses Better Auth"), outside the doc lifecycle: no status, no phase. +- `write_memory {topic, content}` creates or overwrites the entry for that + topic wholesale, so include everything still worth remembering. +- `search_memory {query}` returns matching entries with their full content; + omit the query to list all. Check memory before re-deriving facts. +- Remove stale entries with `delete_doc {path: "memory/.md"}`. +- Long-form knowledge belongs in docs; promote a grown memory with + `write_doc`, then delete the entry. + +## Tasks + +- Work items live in `tasks/` as Backlog.md-shaped files: `task-N` ids, + status in frontmatter ("To Do" | "In Progress" | "Done"), a Description + section, and an Acceptance Criteria checklist. +- `write_task {title, description, acceptance_criteria?}` creates one; + `list_tasks {status?}` shows the board; `set_task_status {id, status}` + moves it. +- `update_task {id, old_str, new_str}` edits in place: check a criterion by + replacing `- [ ] #1 ...` with `- [x] #1 ...`, or append notes. +- Unlike docs, tasks never move between folders; status is frontmatter-only. + +## Conventions + +- Address docs by slug (`api-notes`) or status-relative path + (`research/api-notes.md`). +- Tool failures return `{error: {code, message, recovery}}`; follow the + recovery hint - it names valid values, available slugs, or the doc's new + location when it moved. +- Every doc is also an MCP resource: `docsreader://{workspace}/{path}`. +- Write docs for future readers (humans and agents): a clear title, a short + opening summary, and tags make `search_docs` work well. diff --git a/src-tauri/mcp/src/server/prompts.rs b/src-tauri/mcp/src/server/prompts.rs new file mode 100644 index 0000000..209f213 --- /dev/null +++ b/src-tauri/mcp/src/server/prompts.rs @@ -0,0 +1,84 @@ +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{PromptMessage, Role}; +use rmcp::{prompt, prompt_router}; +use schemars::JsonSchema; +use serde::Deserialize; + +use super::DocsServer; + +#[derive(Deserialize, JsonSchema)] +pub struct StartTaskArgs { + /// Short title for the task. + pub title: String, + /// Extra context: constraints, links, or hints for acceptance criteria. + pub context: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct RecordDecisionArgs { + /// The decision taken, in one sentence. + pub decision: String, + /// Why: the problem, alternatives weighed, constraints. + pub rationale: Option, +} + +fn with_context(base: String, label: &str, extra: Option<&str>) -> String { + match extra { + Some(extra) if !extra.trim().is_empty() => { + format!("{base}\n\n{label}: {}", extra.trim()) + } + _ => base, + } +} + +#[prompt_router(vis = "pub(crate)")] +impl DocsServer { + #[prompt( + name = "start-task", + description = "Scaffold a DocsReader task for a piece of work and start on it: creates the Backlog.md-shaped task with acceptance criteria, moves it to In Progress, and tracks progress by checking criteria off." + )] + async fn start_task(&self, Parameters(args): Parameters) -> Vec { + let text = with_context( + format!( + "Start work on this task: {}\n\n\ + 1. If you have not read it this session, read the docsreader://onboarding resource.\n\ + 2. Create the task with write_task: a one-paragraph description of what and why, \ + plus 2-5 verifiable acceptance criteria.\n\ + 3. Move it to \"In Progress\" with set_task_status before you begin.\n\ + 4. As you complete each criterion, check it off with update_task \ + (replace \"- [ ] #N ...\" with \"- [x] #N ...\"); append implementation notes the same way.\n\ + 5. When every criterion is checked, set the status to \"Done\".", + args.title + ), + "Context", + args.context.as_deref(), + ); + vec![PromptMessage::new_text(Role::User, text)] + } + + #[prompt( + name = "record-decision", + description = "Record a decision as a doc in the workspace: searches for prior related decisions, writes a structured decision doc, and saves a memory pointer when it changes day-to-day behavior." + )] + async fn record_decision( + &self, + Parameters(args): Parameters, + ) -> Vec { + let text = with_context( + format!( + "Record this decision in the workspace: {}\n\n\ + 1. Search first: run search_docs and search_memory for prior related decisions; \ + link or supersede them instead of duplicating.\n\ + 2. Create the doc with write_doc: status \"done\", tags [\"decision\"], a title naming \ + the decision, and a body with sections for Context, Decision, Alternatives considered, \ + and Consequences.\n\ + 3. If the decision changes how agents should work in this workspace day-to-day, also \ + save a short write_memory entry that states the rule and links the doc.", + args.decision + ), + "Rationale", + args.rationale.as_deref(), + ); + vec![PromptMessage::new_text(Role::User, text)] + } +} diff --git a/src-tauri/mcp/src/server/read_tools.rs b/src-tauri/mcp/src/server/read_tools.rs new file mode 100644 index 0000000..8a1efc6 --- /dev/null +++ b/src-tauri/mcp/src/server/read_tools.rs @@ -0,0 +1,207 @@ +use docsreader_core::error::{CoreError, ErrorCode}; +use docsreader_core::read::{ + DocContent, DocFilters, DocSummary, SearchHit, list_docs_core, read_doc_core, search_docs_core, +}; +use docsreader_core::workspace::resolve::ResolvedWorkspace; +use rmcp::handler::server::wrapper::{Json, Parameters}; +use rmcp::model::CallToolResult; +use rmcp::{Peer, RoleServer, tool, tool_router}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{ + DocsServer, TRUNCATION_GUIDANCE, doc_uri, error_result, parse_status, resolve_or_pick, + take_within_budget, +}; + +#[derive(Deserialize, JsonSchema)] +pub struct ReadDocParams { + /// Doc slug (e.g. "api-design-notes") or status-relative path + /// (e.g. "research/api-design-notes.md"). + pub path: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, + /// "concise" (default: frontmatter + snippet) or "detailed" (full body). + pub response_format: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ListDocsParams { + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, + /// Filter by status: "research" | "in-progress" | "done" | "archived". + pub status: Option, + /// Filter by phase subfolder. + pub phase: Option, + /// Filter by tag. Filters AND together. + pub tag: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SearchDocsParams { + /// Search term, matched against title, tags, slug, and content. + pub query: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, + /// Filter by status: "research" | "in-progress" | "done" | "archived". + pub status: Option, + /// Filter by phase subfolder. + pub phase: Option, + /// Filter by tag. Filters AND together. + pub tag: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocEntry { + #[serde(flatten)] + pub doc: DocSummary, + /// Resource URI for this doc (docsreader:///). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct HitEntry { + #[serde(flatten)] + pub hit: SearchHit, + /// Resource URI for this doc (docsreader:///). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ListDocsResult { + pub workspace: ResolvedWorkspace, + pub docs: Vec, + pub truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SearchDocsResult { + pub workspace: ResolvedWorkspace, + pub hits: Vec, + pub truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReadDocResult { + pub workspace: ResolvedWorkspace, + pub doc: DocContent, +} + +fn parse_filters<'a>( + status: Option<&'a str>, + phase: Option<&'a str>, + tag: Option<&'a str>, +) -> Result, CoreError> { + Ok(DocFilters { + status: parse_status(status)?, + phase, + tag, + }) +} + +fn parse_response_format(value: Option<&str>) -> Result { + match value { + None | Some("concise") => Ok(false), + Some("detailed") => Ok(true), + Some(other) => Err(CoreError::new( + ErrorCode::InvalidInput, + format!("unknown response_format {other:?}"), + ) + .with_recovery("valid formats: [concise, detailed]")), + } +} + +#[tool_router(router = read_tool_router, vis = "pub(crate)")] +impl DocsServer { + #[tool( + description = "Read one doc from a DocsReader workspace by slug or status-relative path. Default concise mode returns frontmatter + a snippet; response_format=\"detailed\" returns the full body.", + annotations(read_only_hint = true) + )] + async fn read_doc( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let detailed = parse_response_format(p.response_format.as_deref())?; + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let doc = read_doc_core(&ws.root, &p.path, detailed)?; + Ok(ReadDocResult { workspace: ws, doc }) + } + .await; + result.map(Json).map_err(|e: CoreError| error_result(&e)) + } + + #[tool( + description = "List docs in a DocsReader workspace, newest first. Filter by status, phase, or tag (filters AND together). Results carry docsreader:// resource URIs.", + annotations(read_only_hint = true) + )] + async fn list_docs( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let filters = parse_filters(p.status.as_deref(), p.phase.as_deref(), p.tag.as_deref())?; + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let docs = list_docs_core(&ws.root, &filters)?; + let entries: Vec = docs + .into_iter() + .map(|doc| DocEntry { + uri: doc_uri(&ws.slug, &doc.rel_path), + doc, + }) + .collect(); + let (docs, truncated) = take_within_budget(entries); + Ok(ListDocsResult { + workspace: ws, + docs, + truncated, + guidance: truncated.then(|| TRUNCATION_GUIDANCE.to_string()), + }) + } + .await; + result.map(Json).map_err(|e: CoreError| error_result(&e)) + } + + #[tool( + description = "Search docs in a DocsReader workspace. Ranks matches across title, tags, slug, and content; returns snippets and docsreader:// resource URIs. Combine with status/phase/tag filters to narrow.", + annotations(read_only_hint = true) + )] + async fn search_docs( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let filters = parse_filters(p.status.as_deref(), p.phase.as_deref(), p.tag.as_deref())?; + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let hits = search_docs_core(&ws.root, &p.query, &filters)?; + let entries: Vec = hits + .into_iter() + .map(|hit| HitEntry { + uri: doc_uri(&ws.slug, &hit.doc.rel_path), + hit, + }) + .collect(); + let (hits, truncated) = take_within_budget(entries); + Ok(SearchDocsResult { + workspace: ws, + hits, + truncated, + guidance: truncated.then(|| TRUNCATION_GUIDANCE.to_string()), + }) + } + .await; + result.map(Json).map_err(|e: CoreError| error_result(&e)) + } +} diff --git a/src-tauri/mcp/src/server/resources.rs b/src-tauri/mcp/src/server/resources.rs new file mode 100644 index 0000000..ba07dc4 --- /dev/null +++ b/src-tauri/mcp/src/server/resources.rs @@ -0,0 +1,192 @@ +use docsreader_core::error::CoreError; +use docsreader_core::memory::{MemoryHit, search_memory_core}; +use docsreader_core::path_guard::safe_join; +use docsreader_core::read::{DocFilters, DocSummary, list_docs_core}; +use docsreader_core::tasks::{TaskSummary, list_tasks_core}; +use docsreader_core::write::{DocStatus, locate_doc}; +use rmcp::ErrorData; +use rmcp::model::{ + Annotations, ListResourceTemplatesResult, ListResourcesResult, ReadResourceResult, Resource, + ResourceContents, ResourceTemplate, +}; + +use super::{doc_uri, resolve}; + +pub(crate) const ONBOARDING_URI: &str = "docsreader://onboarding"; +const ONBOARDING_TEXT: &str = include_str!("onboarding.md"); +const MARKDOWN_MIME: &str = "text/markdown"; +const PAGE_SIZE: usize = 100; + +/// Attention hint for clients: active work outranks finished work. +fn status_priority(status: DocStatus) -> f32 { + match status { + DocStatus::InProgress => 0.9, + DocStatus::Research => 0.7, + DocStatus::Done => 0.5, + DocStatus::Archived => 0.2, + } +} + +fn doc_annotations(doc: &DocSummary) -> Annotations { + let annotations = Annotations::default().with_priority(status_priority(doc.status)); + match doc + .modified + .and_then(|s| chrono::DateTime::from_timestamp(i64::try_from(s).ok()?, 0)) + { + Some(ts) => annotations.with_timestamp(ts), + None => annotations, + } +} + +fn onboarding_resource() -> Resource { + Resource::new(ONBOARDING_URI, "onboarding") + .with_title("DocsReader agent onboarding") + .with_description( + "Read this first: how DocsReader workspaces, the status/phase lifecycle, \ + and the doc tools fit together.", + ) + .with_mime_type(MARKDOWN_MIME) + .with_annotations(Annotations::default().with_priority(1.0)) +} + +fn doc_resource(ws_slug: &str, doc: &DocSummary) -> Resource { + let mut resource = Resource::new(doc_uri(ws_slug, &doc.rel_path), doc.rel_path.clone()) + .with_mime_type(MARKDOWN_MIME) + .with_size(doc.size) + .with_annotations(doc_annotations(doc)); + if let Some(title) = &doc.title { + resource = resource.with_title(title.clone()); + } + resource +} + +const MEMORY_PRIORITY: f32 = 0.8; +const TASK_PRIORITY: f32 = 0.6; + +fn memory_resource(ws_slug: &str, hit: &MemoryHit) -> Resource { + let mut resource = Resource::new(doc_uri(ws_slug, &hit.rel_path), hit.rel_path.clone()) + .with_mime_type(MARKDOWN_MIME) + .with_annotations(Annotations::default().with_priority(MEMORY_PRIORITY)); + if let Some(title) = &hit.title { + resource = resource.with_title(title.clone()); + } + resource +} + +fn task_resource(ws_slug: &str, task: &TaskSummary) -> Resource { + let mut resource = Resource::new(doc_uri(ws_slug, &task.rel_path), task.id.clone()) + .with_mime_type(MARKDOWN_MIME) + .with_annotations(Annotations::default().with_priority(TASK_PRIORITY)); + if let Some(title) = &task.title { + resource = resource.with_title(title.clone()); + } + resource +} + +fn protocol_error(err: &CoreError) -> ErrorData { + ErrorData::internal_error( + err.message.clone(), + Some(serde_json::json!({ "error": err })), + ) +} + +fn parse_cursor(cursor: Option) -> Result { + match cursor { + None => Ok(0), + Some(c) => c + .parse() + .map_err(|_| ErrorData::invalid_params(format!("invalid cursor `{c}`"), None)), + } +} + +pub(crate) fn list_resources_page( + cursor: Option, +) -> Result { + let offset = parse_cursor(cursor)?; + let ws = resolve(None).map_err(|e| protocol_error(&e))?; + let mut all = Vec::new(); + if ws.root.is_dir() { + let memories = search_memory_core(&ws.root, None, None).map_err(|e| protocol_error(&e))?; + all.extend(memories.iter().map(|hit| memory_resource(&ws.slug, hit))); + let tasks = list_tasks_core(&ws.root, None, None).map_err(|e| protocol_error(&e))?; + all.extend(tasks.iter().map(|task| task_resource(&ws.slug, task))); + let docs = + list_docs_core(&ws.root, &DocFilters::default()).map_err(|e| protocol_error(&e))?; + all.extend(docs.iter().map(|doc| doc_resource(&ws.slug, doc))); + } + + let mut resources = Vec::new(); + if offset == 0 { + resources.push(onboarding_resource()); + } + resources.extend(all.iter().skip(offset).take(PAGE_SIZE).cloned()); + + let next = offset + PAGE_SIZE; + let mut result = ListResourcesResult::with_all_items(resources); + if next < all.len() { + result.next_cursor = Some(next.to_string()); + } + Ok(result) +} + +pub(crate) fn list_templates() -> ListResourceTemplatesResult { + let template = ResourceTemplate::new("docsreader://{workspace}/{+path}", "doc") + .with_title("DocsReader doc") + .with_description( + "A markdown doc in a DocsReader workspace. `workspace` is a workspace slug \ + (see list_workspaces); `path` is the doc's status-relative path, e.g. \ + `research/api-notes.md`.", + ) + .with_mime_type(MARKDOWN_MIME); + ListResourceTemplatesResult::with_all_items(vec![template]) +} + +pub(crate) fn read_resource_at(uri: &str) -> Result { + if uri == ONBOARDING_URI { + return Ok(ReadResourceResult::new(vec![ + ResourceContents::text(ONBOARDING_TEXT, uri).with_mime_type(MARKDOWN_MIME), + ])); + } + let (ws_slug, rel_path) = split_doc_uri(uri)?; + let path = resolve(Some(ws_slug)) + .and_then(|ws| { + if rel_path.starts_with("memory/") || rel_path.starts_with("tasks/") { + let path = safe_join(&ws.root, rel_path)?; + if !path.is_file() { + return Err(CoreError::new( + docsreader_core::error::ErrorCode::DocNotFound, + format!("nothing at {rel_path:?}"), + ) + .with_recovery("call search_memory or list_tasks to see existing entries")); + } + Ok(path) + } else { + locate_doc(&ws.root, rel_path).map(|doc| doc.path) + } + }) + .map_err(|err| { + ErrorData::resource_not_found( + err.message.clone(), + Some(serde_json::json!({ "uri": uri, "error": err })), + ) + })?; + let text = std::fs::read_to_string(&path) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok(ReadResourceResult::new(vec![ + ResourceContents::text(text, uri).with_mime_type(MARKDOWN_MIME), + ])) +} + +fn split_doc_uri(uri: &str) -> Result<(&str, &str), ErrorData> { + uri.strip_prefix("docsreader://") + .and_then(|rest| rest.split_once('/')) + .filter(|(ws, path)| !ws.is_empty() && !path.is_empty()) + .ok_or_else(|| { + ErrorData::resource_not_found( + format!("unknown resource URI `{uri}`"), + Some(serde_json::json!({ + "expected": "docsreader://{workspace}/{path} or docsreader://onboarding", + })), + ) + }) +} diff --git a/src-tauri/mcp/src/server/task_tools.rs b/src-tauri/mcp/src/server/task_tools.rs new file mode 100644 index 0000000..34bbfb7 --- /dev/null +++ b/src-tauri/mcp/src/server/task_tools.rs @@ -0,0 +1,226 @@ +use docsreader_core::error::CoreError; +use docsreader_core::tasks::{ + NewTask, TaskSummary, list_tasks_core, set_task_status_core, update_task_core, write_task_core, +}; +use docsreader_core::workspace::resolve::ResolvedWorkspace; +use rmcp::handler::server::wrapper::{Json, Parameters}; +use rmcp::model::CallToolResult; +use rmcp::{Peer, RoleServer, tool, tool_router}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{ + DocsServer, TRUNCATION_GUIDANCE, client_name, doc_uri, ensure_workspace_exists, error_result, + resolve_or_pick, take_within_budget, +}; + +#[derive(Deserialize, JsonSchema)] +pub struct WriteTaskParams { + /// Short task title. + pub title: String, + /// What the task is and why (markdown; becomes the Description section). + pub description: String, + /// Acceptance criteria; rendered as a checklist the assignee ticks off. + pub acceptance_criteria: Option>, + /// "To Do" (default) | "In Progress" | "Done". + pub status: Option, + /// "high" | "medium" | "low". + pub priority: Option, + /// Who the task is assigned to. + pub assignee: Option>, + /// Topic labels. + pub labels: Option>, + /// Ids of tasks this one depends on, e.g. ["task-2"]. + pub dependencies: Option>, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ListTasksParams { + /// Filter by status: "To Do" | "In Progress" | "Done". + pub status: Option, + /// Filter by label. + pub label: Option, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SetTaskStatusParams { + /// Task id (e.g. "task-3") or tasks/ relative path. + pub id: String, + /// Target status: "To Do" | "In Progress" | "Done". + pub status: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct UpdateTaskParams { + /// Task id (e.g. "task-3") or tasks/ relative path. + pub id: String, + /// Exact text to replace; must appear exactly once in the task file. + /// To check an acceptance criterion, replace "- [ ] #1 ..." with + /// "- [x] #1 ...". + pub old_str: String, + /// Replacement text. Omit to delete old_str. + pub new_str: Option, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskChangeResult { + pub ok: bool, + pub workspace: ResolvedWorkspace, + #[serde(flatten)] + pub task: TaskSummary, + /// Resource URI for this task (docsreader:///). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskEntry { + #[serde(flatten)] + pub task: TaskSummary, + /// Resource URI for this task (docsreader:///). + pub uri: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ListTasksResult { + pub workspace: ResolvedWorkspace, + pub tasks: Vec, + pub truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance: Option, +} + +fn task_change(ws: ResolvedWorkspace, task: TaskSummary) -> Json { + Json(TaskChangeResult { + ok: true, + uri: doc_uri(&ws.slug, &task.rel_path), + workspace: ws, + task, + }) +} + +#[tool_router(router = task_tool_router, vis = "pub(crate)")] +impl DocsServer { + #[tool( + description = "Create a task in the workspace's tasks/ folder using the Backlog.md file shape (task-N id, frontmatter status, Description + Acceptance Criteria checklist). Unlike docs, task status lives in frontmatter, not folders.", + annotations(destructive_hint = false) + )] + async fn write_task( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let reporter = client_name(&peer); + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + ensure_workspace_exists(&ws)?; + let task = NewTask { + title: &p.title, + description: &p.description, + acceptance_criteria: p.acceptance_criteria.clone().unwrap_or_default(), + status: p.status.as_deref(), + priority: p.priority.as_deref(), + assignee: p.assignee.clone().unwrap_or_default(), + labels: p.labels.clone().unwrap_or_default(), + dependencies: p.dependencies.clone().unwrap_or_default(), + reporter: reporter.as_deref(), + }; + let written = write_task_core(&ws.root, &task).await?; + Ok::<_, CoreError>((ws, written)) + } + .await; + result + .map(|(ws, task)| task_change(ws, task)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "List tasks in the workspace, ordered by id. Filter by status (\"To Do\" | \"In Progress\" | \"Done\") or label.", + annotations(read_only_hint = true) + )] + async fn list_tasks( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let tasks = if ws.root.is_dir() { + list_tasks_core(&ws.root, p.status.as_deref(), p.label.as_deref())? + } else { + Vec::new() + }; + let entries: Vec = tasks + .into_iter() + .map(|task| TaskEntry { + uri: doc_uri(&ws.slug, &task.rel_path), + task, + }) + .collect(); + let (tasks, truncated) = take_within_budget(entries); + Ok::<_, CoreError>(ListTasksResult { + workspace: ws, + tasks, + truncated, + guidance: truncated.then(|| TRUNCATION_GUIDANCE.to_string()), + }) + } + .await; + result.map(Json).map_err(|e| error_result(&e)) + } + + #[tool( + description = "Move a task to a different status (\"To Do\" | \"In Progress\" | \"Done\") by rewriting its frontmatter; updated_date is stamped.", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn set_task_status( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let task = set_task_status_core(&ws.root, &p.id, &p.status).await?; + Ok::<_, CoreError>((ws, task)) + } + .await; + result + .map(|(ws, task)| task_change(ws, task)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Edit a task file by exact string replacement (same contract as update_doc). Typical use: check an acceptance criterion by replacing \"- [ ] #1 ...\" with \"- [x] #1 ...\", or append implementation notes." + )] + async fn update_task( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let task = update_task_core( + &ws.root, + &p.id, + &p.old_str, + p.new_str.as_deref().unwrap_or(""), + ) + .await?; + Ok::<_, CoreError>((ws, task)) + } + .await; + result + .map(|(ws, task)| task_change(ws, task)) + .map_err(|e| error_result(&e)) + } +} diff --git a/src-tauri/mcp/src/server/workspace_tools.rs b/src-tauri/mcp/src/server/workspace_tools.rs new file mode 100644 index 0000000..8222d25 --- /dev/null +++ b/src-tauri/mcp/src/server/workspace_tools.rs @@ -0,0 +1,121 @@ +use std::path::PathBuf; + +use docsreader_core::error::{CoreError, ErrorCode}; +use docsreader_core::workspace::WorkspaceScope; +use docsreader_core::workspace::init::{InitializedWorkspace, init_workspace_core}; +use docsreader_core::workspace::registry::{default_registry_path, load_registry}; +use docsreader_core::workspace::resolve::DEFAULT_WORKSPACE_DIR; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::CallToolResult; +use rmcp::{tool, tool_router}; +use schemars::JsonSchema; +use serde::Deserialize; + +use super::{DocsServer, error_result, home_dir, success_json}; + +#[derive(Deserialize, JsonSchema)] +pub struct InitWorkspaceParams { + /// Project directory; the workspace is created at /notes. Omit to + /// create the user workspace at ~/notes. + pub path: Option, + /// Workspace scope: "user" (~/notes) or "project" (/notes). + /// Defaults to project when path is given, user otherwise. + pub scope: Option, + /// Workspace slug; defaults to the project folder name, or "notes" for user scope. + pub slug: Option, + /// Human-readable display name. + pub name: Option, +} + +#[tool_router(router = workspace_tool_router, vis = "pub(crate)")] +impl DocsServer { + #[tool( + description = "Health check. Returns \"pong\" if the server is alive.", + annotations(read_only_hint = true) + )] + async fn ping(&self) -> String { + "pong".to_string() + } + + #[tool( + description = "List all known DocsReader workspaces: registered project workspaces plus the default user workspace (~/notes). Call this when a workspace slug is unknown or before choosing where to write.", + annotations(read_only_hint = true) + )] + async fn list_workspaces(&self) -> CallToolResult { + match list_workspaces_impl() { + Ok(value) => success_json(value), + Err(err) => error_result(&err), + } + } + + #[tool( + description = "Create a new DocsReader workspace and register it. No args: creates the user workspace at ~/notes. With path: creates a project workspace at /notes. Fails if the target already has content.", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn init_workspace( + &self, + Parameters(p): Parameters, + ) -> CallToolResult { + match init_workspace_impl(&p) { + Ok(ws) => success_json(serde_json::json!({ + "ok": true, + "workspacePath": ws.root, + "slug": ws.slug, + "name": ws.name, + "scope": ws.scope, + })), + Err(err) => error_result(&err), + } + } +} + +fn list_workspaces_impl() -> Result { + let home = home_dir()?; + let entries = load_registry(&default_registry_path(&home))?; + let default_root = home.join(DEFAULT_WORKSPACE_DIR); + Ok(serde_json::json!({ + "workspaces": entries, + "defaultUserWorkspace": { + "path": default_root, + "exists": default_root.is_dir(), + "scope": "user", + }, + })) +} + +fn parse_scope(value: &str) -> Result { + match value { + "user" => Ok(WorkspaceScope::User), + "project" => Ok(WorkspaceScope::Project), + other => Err( + CoreError::new(ErrorCode::InvalidInput, format!("unknown scope {other:?}")) + .with_recovery("valid scopes: [user, project]"), + ), + } +} + +fn init_workspace_impl(p: &InitWorkspaceParams) -> Result { + let scope = match (p.scope.as_deref(), p.path.as_deref()) { + (Some(s), _) => parse_scope(s)?, + (None, Some(_)) => WorkspaceScope::Project, + (None, None) => WorkspaceScope::User, + }; + let home = home_dir()?; + let root = match scope { + WorkspaceScope::User => home.join(DEFAULT_WORKSPACE_DIR), + WorkspaceScope::Project => { + let base = match p.path.as_deref() { + Some(path) => PathBuf::from(path), + None => std::env::current_dir()?, + }; + base.join(DEFAULT_WORKSPACE_DIR) + } + }; + init_workspace_core( + &root, + p.slug.as_deref(), + p.name.as_deref(), + scope, + &default_registry_path(&home), + ) +} diff --git a/src-tauri/mcp/src/server/write_tools.rs b/src-tauri/mcp/src/server/write_tools.rs new file mode 100644 index 0000000..f90cd6d --- /dev/null +++ b/src-tauri/mcp/src/server/write_tools.rs @@ -0,0 +1,338 @@ +use std::path::Path; + +use docsreader_core::delete::delete_doc_core; +use docsreader_core::error::CoreError; +use docsreader_core::memory::delete_memory_core; +use docsreader_core::rename::rename_doc_core; +use docsreader_core::tasks::delete_task_core; +use docsreader_core::update::str_replace_core; +use docsreader_core::workspace::resolve::ResolvedWorkspace; +use docsreader_core::write::{ + DocStatus, NewDoc, WrittenDoc, archive_doc_core, set_phase_core, set_status_core, + write_doc_core, +}; +use rmcp::handler::server::wrapper::{Json, Parameters}; +use rmcp::model::{CallToolResult, ContentBlock}; +use rmcp::{Peer, RoleServer, tool, tool_router}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{ + DocsServer, client_name, doc_resource_link, doc_uri, ensure_workspace_exists, error_result, + resolve_or_pick, +}; + +#[derive(Deserialize, JsonSchema)] +pub struct WriteDocParams { + /// Short human-readable title; becomes the doc's slug and filename. + pub title: String, + /// Markdown body. Frontmatter is generated automatically; do not include it. + pub body: String, + /// Lifecycle status: one of "research", "in-progress", "done", "archived". + pub status: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default + /// workspace (project ./notes if present, else user ~/notes). + pub workspace: Option, + /// Phase subfolder within the status folder, e.g. "discovery" or "v2-launch". + pub phase: Option, + /// Owner of the doc (person or agent name). + pub owner: Option, + /// Topic tags. + pub tags: Option>, + /// Priority label, e.g. "high" | "medium" | "low". + pub priority: Option, + /// Due date in ISO format (YYYY-MM-DD). + pub due: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct UpdateDocParams { + /// Doc slug or status-relative path (e.g. "research/api-notes.md"). + pub path: String, + /// Exact text to replace; must appear exactly once in the doc. + pub old_str: String, + /// Replacement text. Omit to delete old_str. + pub new_str: Option, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SetStatusParams { + /// Doc slug or status-relative path. + pub path: String, + /// Target status: "research" | "in-progress" | "done" | "archived". + pub status: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SetPhaseParams { + /// Doc slug or status-relative path. + pub path: String, + /// Phase subfolder name (e.g. "v2-launch"). Omit to move the doc out of + /// its phase subfolder. + pub phase: Option, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ArchiveParams { + /// Doc slug or status-relative path. + pub path: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct DeleteDocParams { + /// Doc slug or status-relative path. + pub path: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct RenameDocParams { + /// Doc slug or status-relative path. + pub path: String, + /// New human-readable title; becomes the frontmatter title and the new + /// slug/filename. + pub new_title: String, + /// Workspace slug (see list_workspaces). Omit to use the resolved default. + pub workspace: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocDeleteResult { + pub ok: bool, + pub workspace: ResolvedWorkspace, + pub slug: String, + /// Status-relative path the doc was deleted from. + pub rel_path: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocChangeResult { + pub ok: bool, + pub workspace: ResolvedWorkspace, + #[serde(flatten)] + pub doc: WrittenDoc, + /// Resource URI for this doc (docsreader:///). + pub uri: String, +} + +fn change_result(ws: ResolvedWorkspace, doc: WrittenDoc) -> Json { + Json(DocChangeResult { + ok: true, + uri: doc_uri(&ws.slug, &doc.rel_path), + workspace: ws, + doc, + }) +} + +async fn located( + peer: &Peer, + workspace: Option<&str>, +) -> Result { + let ws = resolve_or_pick(peer, workspace).await?; + ensure_workspace_exists(&ws)?; + Ok(ws) +} + +#[tool_router(router = write_tool_router, vis = "pub(crate)")] +impl DocsServer { + #[tool( + description = "Create a markdown doc in a DocsReader workspace. The doc lands in the folder matching its status (research | in-progress | done | archived), optionally inside a phase subfolder, with generated frontmatter. Prefer this over writing files directly: it handles slugs, collisions, metadata, and git staging.", + annotations(destructive_hint = false) + )] + async fn write_doc( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> CallToolResult { + let created_by = client_name(&peer); + match write_doc_impl(&peer, &p, created_by.as_deref()).await { + Ok((ws, doc)) => { + let link = doc_resource_link(&ws.slug, &doc.rel_path, &p.title); + let body = serde_json::json!({ + "ok": true, + "path": doc.path, + "relPath": doc.rel_path, + "slug": doc.slug, + "status": doc.status, + "phase": doc.phase, + "workspace": ws, + }); + CallToolResult::success(vec![ContentBlock::text(body.to_string()), link]) + } + Err(err) => error_result(&err), + } + } + + #[tool( + description = "Edit a doc in place by exact string replacement (str_replace). old_str must appear exactly once; on failure the error explains whether it was missing or ambiguous." + )] + async fn update_doc( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = located(&peer, p.workspace.as_deref()).await?; + let doc = str_replace_core( + &ws.root, + &p.path, + &p.old_str, + p.new_str.as_deref().unwrap_or(""), + ) + .await?; + Ok::<_, CoreError>((ws, doc)) + } + .await; + result + .map(|(ws, doc)| change_result(ws, doc)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Move a doc to a different lifecycle status (research | in-progress | done | archived). The move IS the status change; any phase subfolder is preserved.", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn set_status( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let status = DocStatus::parse(&p.status)?; + let ws = located(&peer, p.workspace.as_deref()).await?; + let doc = set_status_core(&ws.root, &p.path, status).await?; + Ok::<_, CoreError>((ws, doc)) + } + .await; + result + .map(|(ws, doc)| change_result(ws, doc)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Move a doc into a phase subfolder within its status (or out of it when phase is omitted).", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn set_phase( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = located(&peer, p.workspace.as_deref()).await?; + let doc = set_phase_core(&ws.root, &p.path, p.phase.as_deref()).await?; + Ok::<_, CoreError>((ws, doc)) + } + .await; + result + .map(|(ws, doc)| change_result(ws, doc)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Archive a doc: shorthand for set_status(path, \"archived\").", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn archive( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = located(&peer, p.workspace.as_deref()).await?; + let doc = archive_doc_core(&ws.root, &p.path).await?; + Ok::<_, CoreError>((ws, doc)) + } + .await; + result + .map(|(ws, doc)| change_result(ws, doc)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Rename a doc: the new title becomes the frontmatter title and a new slug/filename. The doc stays in its status and phase; renaming onto a slug another doc uses is a conflict.", + annotations(destructive_hint = false, idempotent_hint = true) + )] + async fn rename_doc( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = located(&peer, p.workspace.as_deref()).await?; + let doc = rename_doc_core(&ws.root, &p.path, &p.new_title).await?; + Ok::<_, CoreError>((ws, doc)) + } + .await; + result + .map(|(ws, doc)| change_result(ws, doc)) + .map_err(|e| error_result(&e)) + } + + #[tool( + description = "Permanently delete a doc (or a memory entry via \"memory/.md\", or a task via its \"tasks/...\" path) from the workspace. Outside git history this cannot be undone; prefer archive to keep a doc browsable.", + annotations(destructive_hint = true, idempotent_hint = true) + )] + async fn delete_doc( + &self, + peer: Peer, + Parameters(p): Parameters, + ) -> Result, CallToolResult> { + let result = async { + let ws = located(&peer, p.workspace.as_deref()).await?; + let (slug, rel_path) = if p.path.starts_with("memory/") { + let entry = delete_memory_core(&ws.root, &p.path).await?; + (entry.slug, entry.rel_path) + } else if p.path.starts_with("tasks/") { + let task = delete_task_core(&ws.root, &p.path).await?; + (task.id, task.rel_path) + } else { + let doc = delete_doc_core(&ws.root, &p.path).await?; + (doc.slug, doc.rel_path) + }; + Ok::<_, CoreError>((ws, slug, rel_path)) + } + .await; + result + .map(|(ws, slug, rel_path)| { + Json(DocDeleteResult { + ok: true, + workspace: ws, + slug, + rel_path, + }) + }) + .map_err(|e| error_result(&e)) + } +} + +async fn write_doc_impl( + peer: &Peer, + p: &WriteDocParams, + created_by: Option<&str>, +) -> Result<(ResolvedWorkspace, WrittenDoc), CoreError> { + let status = DocStatus::parse(&p.status)?; + let ws = located(peer, p.workspace.as_deref()).await?; + let doc = NewDoc { + created_by, + phase: p.phase.as_deref(), + owner: p.owner.as_deref(), + tags: p.tags.clone().unwrap_or_default(), + priority: p.priority.as_deref(), + due: p.due.as_deref(), + ..NewDoc::new(&p.title, &p.body, status) + }; + let written = write_doc_core(Path::new(&ws.root), &doc).await?; + Ok((ws, written)) +} diff --git a/src-tauri/mcp/tests/stdio.rs b/src-tauri/mcp/tests/stdio.rs new file mode 100644 index 0000000..ef625df --- /dev/null +++ b/src-tauri/mcp/tests/stdio.rs @@ -0,0 +1,443 @@ +//! End-to-end tests driving the real docsreader-mcp binary over stdio. +//! Each test gets an isolated HOME, so nothing touches the developer's +//! workspaces; any stray non-JSON byte on stdout fails the harness. + +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use serde_json::{Value, json}; + +struct McpClient { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl McpClient { + fn spawn(home: &Path, envs: &[(&str, &str)], capabilities: Value) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_docsreader-mcp")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .env("HOME", home) + .envs(envs.iter().copied()) + .spawn() + .expect("spawn docsreader-mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let mut client = Self { + child, + stdin, + stdout, + next_id: 0, + }; + client.request( + "initialize", + json!({ + "protocolVersion": "2025-11-25", + "capabilities": capabilities, + "clientInfo": {"name": "stdio-test", "version": "0"}, + }), + no_server_requests, + ); + client.notify("notifications/initialized"); + client + } + + fn send(&mut self, value: Value) { + let mut line = value.to_string(); + line.push('\n'); + self.stdin.write_all(line.as_bytes()).expect("write stdin"); + self.stdin.flush().expect("flush stdin"); + } + + fn notify(&mut self, method: &str) { + self.send(json!({"jsonrpc": "2.0", "method": method})); + } + + fn read_message(&mut self) -> Value { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).expect("read stdout"); + assert!(n > 0, "server closed stdout unexpectedly"); + serde_json::from_str(&line).expect("stdout carried a non-JSON line") + } + + /// Send a request and pump messages until its response arrives. Server- + /// initiated requests (e.g. elicitation/create) are answered by + /// `on_request`, which returns the result to reply with. + fn request( + &mut self, + method: &str, + params: Value, + mut on_request: impl FnMut(&Value) -> Value, + ) -> Value { + self.next_id += 1; + let id = self.next_id; + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + loop { + let msg = self.read_message(); + if msg.get("id") == Some(&json!(id)) && msg.get("method").is_none() { + assert!( + msg.get("error").is_none(), + "protocol error for {method}: {msg}" + ); + return msg["result"].clone(); + } + if let (Some(req_id), Some(_)) = (msg.get("id"), msg.get("method")) { + let reply = on_request(&msg); + let req_id = req_id.clone(); + self.send(json!({"jsonrpc": "2.0", "id": req_id, "result": reply})); + } + } + } + + /// Call a tool; returns (payload parsed from the first text block, isError). + fn call(&mut self, tool: &str, args: Value) -> (Value, bool) { + self.call_with(tool, args, no_server_requests) + } + + fn call_with( + &mut self, + tool: &str, + args: Value, + on_request: impl FnMut(&Value) -> Value, + ) -> (Value, bool) { + let result = self.request( + "tools/call", + json!({"name": tool, "arguments": args}), + on_request, + ); + let text = result["content"][0]["text"] + .as_str() + .expect("tool result carries a text block"); + let payload = serde_json::from_str(text).expect("tool text block is JSON"); + (payload, result["isError"].as_bool().unwrap_or(false)) + } +} + +impl Drop for McpClient { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn no_server_requests(msg: &Value) -> Value { + panic!("unexpected server-initiated request: {msg}"); +} + +fn temp_home() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") +} + +fn init_project(client: &mut McpClient, home: &Path, slug: &str) -> String { + let project = home.join(format!("{slug}-proj")); + std::fs::create_dir_all(&project).unwrap(); + let (payload, is_err) = client.call( + "init_workspace", + json!({"path": project.to_str().unwrap(), "slug": slug}), + ); + assert!(!is_err, "init_workspace failed: {payload}"); + project.to_str().unwrap().to_string() +} + +#[test] +fn doc_lifecycle_round_trip() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + init_project(&mut c, home.path(), "life"); + + let (doc, is_err) = c.call( + "write_doc", + json!({ + "title": "API Design", + "body": "# API Design\n\nfirst draft", + "status": "research", + "workspace": "life", + "tags": ["api"], + }), + ); + assert!(!is_err, "{doc}"); + assert_eq!(doc["ok"], true); + assert_eq!(doc["slug"], "api-design"); + assert_eq!(doc["status"], "research"); + + let (list, _) = c.call("list_docs", json!({"workspace": "life"})); + assert_eq!(list["docs"].as_array().unwrap().len(), 1); + let uri = list["docs"][0]["uri"].as_str().unwrap().to_string(); + assert!(uri.starts_with("docsreader://life/"), "{uri}"); + + let (read, _) = c.call( + "read_doc", + json!({"path": "api-design", "workspace": "life", "response_format": "detailed"}), + ); + assert!(read.to_string().contains("first draft")); + + let (updated, is_err) = c.call( + "update_doc", + json!({ + "path": "api-design", + "old_str": "first draft", + "new_str": "revised draft", + "workspace": "life", + }), + ); + assert!(!is_err, "{updated}"); + let (read, _) = c.call( + "read_doc", + json!({"path": "api-design", "workspace": "life", "response_format": "detailed"}), + ); + assert!(read.to_string().contains("revised draft")); + + let (moved, _) = c.call( + "set_status", + json!({"path": "api-design", "status": "in-progress", "workspace": "life"}), + ); + assert!( + moved["relPath"] + .as_str() + .unwrap() + .starts_with("in-progress/") + ); + + let (archived, _) = c.call( + "archive", + json!({"path": "api-design", "workspace": "life"}), + ); + assert!( + archived["relPath"] + .as_str() + .unwrap() + .starts_with("archived/") + ); + + let (list, _) = c.call( + "list_docs", + json!({"workspace": "life", "status": "archived"}), + ); + assert_eq!(list["docs"].as_array().unwrap().len(), 1); +} + +#[test] +fn resources_and_prompts_are_served() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + let project = init_project(&mut c, home.path(), "res"); + drop(c); + // resources/list serves the ambient workspace; point it at the project. + let mut c = McpClient::spawn( + home.path(), + &[("CLAUDE_PROJECT_DIR", project.as_str())], + json!({}), + ); + c.call( + "write_doc", + json!({"title": "Findings", "body": "insight", "status": "done", "workspace": "res"}), + ); + + let listed = c.request("resources/list", json!({}), no_server_requests); + let uris: Vec<&str> = listed["resources"] + .as_array() + .unwrap() + .iter() + .filter_map(|r| r["uri"].as_str()) + .collect(); + assert!(uris.contains(&"docsreader://onboarding"), "{uris:?}"); + + let onboarding = c.request( + "resources/read", + json!({"uri": "docsreader://onboarding"}), + no_server_requests, + ); + let text = onboarding["contents"][0]["text"].as_str().unwrap(); + assert!( + text.contains("workspace"), + "onboarding should explain the model" + ); + + let doc_uri = uris + .iter() + .find(|u| u.starts_with("docsreader://res/")) + .expect("written doc listed as a resource"); + let doc = c.request( + "resources/read", + json!({"uri": doc_uri}), + no_server_requests, + ); + assert!( + doc["contents"][0]["text"] + .as_str() + .unwrap() + .contains("insight") + ); + + let prompts = c.request("prompts/list", json!({}), no_server_requests); + let names: Vec<&str> = prompts["prompts"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p["name"].as_str()) + .collect(); + assert!(names.contains(&"start-task"), "{names:?}"); + assert!(names.contains(&"record-decision"), "{names:?}"); + + let prompt = c.request( + "prompts/get", + json!({"name": "start-task", "arguments": {"title": "Ship v1"}}), + no_server_requests, + ); + assert!(!prompt["messages"].as_array().unwrap().is_empty()); +} + +#[test] +fn memory_last_write_wins_round_trip() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + init_project(&mut c, home.path(), "mem"); + + let (first, is_err) = c.call( + "write_memory", + json!({"topic": "deploy target", "content": "we deploy to staging", "workspace": "mem"}), + ); + assert!(!is_err, "{first}"); + let (second, _) = c.call( + "write_memory", + json!({"topic": "deploy target", "content": "we deploy to prod now", "workspace": "mem"}), + ); + assert_eq!(second["created"], false, "overwrite, not new entry"); + + let (found, _) = c.call( + "search_memory", + json!({"query": "deploy", "workspace": "mem"}), + ); + let serialized = found.to_string(); + assert!(serialized.contains("prod now")); + assert!(!serialized.contains("staging"), "old content must be gone"); + assert_eq!(found["memories"].as_array().unwrap().len(), 1); +} + +#[test] +fn tasks_round_trip() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + init_project(&mut c, home.path(), "tsk"); + + let (task, is_err) = c.call( + "write_task", + json!({ + "title": "Wire CI", + "description": "Add the pipeline", + "acceptance_criteria": ["pipeline runs on PR"], + "workspace": "tsk", + }), + ); + assert!(!is_err, "{task}"); + let id = task["id"].as_str().unwrap().to_string(); + assert_eq!(task["status"], "To Do"); + + let (moved, _) = c.call( + "set_task_status", + json!({"id": id, "status": "In Progress", "workspace": "tsk"}), + ); + assert_eq!(moved["status"], "In Progress"); + + let (checked, is_err) = c.call( + "update_task", + json!({ + "id": id, + "old_str": "- [ ] #1 pipeline runs on PR", + "new_str": "- [x] #1 pipeline runs on PR", + "workspace": "tsk", + }), + ); + assert!(!is_err, "{checked}"); + + let (list, _) = c.call( + "list_tasks", + json!({"workspace": "tsk", "status": "In Progress"}), + ); + assert_eq!(list["tasks"].as_array().unwrap().len(), 1); +} + +#[test] +fn traversal_and_unknown_slug_are_tool_errors_not_protocol_errors() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + init_project(&mut c, home.path(), "sec"); + + let (payload, is_err) = c.call( + "read_doc", + json!({"path": "../../../etc/passwd", "workspace": "sec"}), + ); + assert!(is_err, "traversal must be rejected: {payload}"); + assert!(payload["error"]["code"].is_string()); + + let (payload, is_err) = c.call("list_docs", json!({"workspace": "ghost"})); + assert!(is_err); + assert_eq!(payload["error"]["code"], "workspace_not_found"); + assert!( + payload["error"]["recovery"] + .as_str() + .unwrap() + .contains("sec"), + "recovery lists known workspaces: {payload}" + ); +} + +#[test] +fn claude_project_dir_auto_selects_workspace() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + let project = init_project(&mut c, home.path(), "auto"); + drop(c); + + let mut c = McpClient::spawn( + home.path(), + &[("CLAUDE_PROJECT_DIR", project.as_str())], + json!({}), + ); + let (list, is_err) = c.call("list_docs", json!({})); + assert!(!is_err, "{list}"); + assert_eq!(list["workspace"]["slug"], "auto"); +} + +#[test] +fn unknown_slug_offers_elicitation_picker_and_accept_resolves() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({"elicitation": {}})); + init_project(&mut c, home.path(), "pick"); + + let mut picker = None; + let (list, is_err) = c.call_with("list_docs", json!({"workspace": "ghost"}), |msg| { + assert_eq!(msg["method"], "elicitation/create"); + picker = Some(msg["params"].clone()); + json!({"action": "accept", "content": {"workspace": "pick"}}) + }); + assert!(!is_err, "{list}"); + assert_eq!(list["workspace"]["slug"], "pick"); + + let picker = picker.expect("server sent an elicitation request"); + assert_eq!(picker["mode"], "form"); + let choices = &picker["requestedSchema"]["properties"]["workspace"]["enum"]; + assert!( + choices.as_array().unwrap().contains(&json!("pick")), + "{picker}" + ); +} + +#[test] +fn elicitation_decline_falls_back_to_recovery_error() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({"elicitation": {}})); + init_project(&mut c, home.path(), "dec"); + + let (payload, is_err) = c.call_with( + "list_docs", + json!({"workspace": "ghost"}), + |_| json!({"action": "decline"}), + ); + assert!(is_err); + assert_eq!(payload["error"]["code"], "workspace_not_found"); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f442318..3c1d3e0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -50,8 +50,7 @@ "entitlements": null, "exceptionDomain": null, "frameworks": [] - }, - "createUpdaterArtifacts": true + } }, "plugins": { "updater": { diff --git a/src-tauri/tauri.sidecar.conf.json b/src-tauri/tauri.sidecar.conf.json new file mode 100644 index 0000000..267e792 --- /dev/null +++ b/src-tauri/tauri.sidecar.conf.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "build": { + "beforeBuildCommand": "bun run build && bun run build:sidecar" + }, + "bundle": { + "externalBin": ["binaries/docsreader-mcp"], + "createUpdaterArtifacts": true + } +} From aca3bc11db464e27da58fec30c387144071e9f5a Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Fri, 3 Jul 2026 12:50:06 +0800 Subject: [PATCH 03/10] feat(settings): one-click AI agent registration pane Settings gains an AI agents section that detects installed MCP clients and registers the bundled server per client. Long config paths truncate with the full path on hover; the dialog panes carry min-w-0 so intrinsic-width content cannot push past the dialog bounds. --- .../settings/AgentsSection.test.tsx | 81 +++++++++++ src/components/settings/AgentsSection.tsx | 130 ++++++++++++++++++ .../settings/SettingsDialog.test.tsx | 53 +++++++ src/components/settings/SettingsDialog.tsx | 29 ++-- src/lib/agents.ts | 29 ++++ 5 files changed, 308 insertions(+), 14 deletions(-) create mode 100644 src/components/settings/AgentsSection.test.tsx create mode 100644 src/components/settings/AgentsSection.tsx create mode 100644 src/components/settings/SettingsDialog.test.tsx create mode 100644 src/lib/agents.ts diff --git a/src/components/settings/AgentsSection.test.tsx b/src/components/settings/AgentsSection.test.tsx new file mode 100644 index 0000000..cd2433c --- /dev/null +++ b/src/components/settings/AgentsSection.test.tsx @@ -0,0 +1,81 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import type { AgentClient } from "@/lib/agents"; +import { AgentsSection } from "./AgentsSection"; + +vi.mock("@/lib/agents", async (importOriginal) => ({ + ...(await importOriginal()), + detectAgentClients: vi.fn(), + connectAgentClient: vi.fn(), +})); + +import { connectAgentClient, detectAgentClients } from "@/lib/agents"; + +const CLIENTS: AgentClient[] = [ + { + id: "claude-code", + name: "Claude Code", + detected: true, + status: "disconnected", + configPath: "/home/u/.claude.json", + }, + { + id: "cursor", + name: "Cursor", + detected: true, + status: "connected", + configPath: "/home/u/.cursor/mcp.json", + }, + { + id: "windsurf", + name: "Windsurf", + detected: false, + status: "disconnected", + configPath: "/home/u/.codeium/windsurf/mcp_config.json", + }, +]; + +beforeEach(() => { + vi.mocked(detectAgentClients).mockResolvedValue(CLIENTS); + vi.mocked(connectAgentClient).mockReset(); +}); + +describe("AgentsSection", () => { + it("lists detected clients with status and marks undetected ones", async () => { + render(); + expect(await screen.findByText("Claude Code")).toBeInTheDocument(); + expect(screen.getByText("Connected")).toBeInTheDocument(); + expect(screen.getByText("Not detected")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument(); + expect(screen.getByText("/home/u/.claude.json")).toHaveAttribute( + "title", + "/home/u/.claude.json" + ); + }); + + it("connects a client and flips its row to connected", async () => { + vi.mocked(connectAgentClient).mockResolvedValue({ + ...CLIENTS[0], + status: "connected", + }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Connect" })); + expect(connectAgentClient).toHaveBeenCalledWith("claude-code"); + await waitFor(() => + expect(screen.getAllByText("Connected")).toHaveLength(2) + ); + }); + + it("shows the error on the row when connect fails", async () => { + vi.mocked(connectAgentClient).mockRejectedValue( + "config.json is not valid JSON" + ); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Connect" })); + expect( + await screen.findByText("config.json is not valid JSON") + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled(); + }); +}); diff --git a/src/components/settings/AgentsSection.tsx b/src/components/settings/AgentsSection.tsx new file mode 100644 index 0000000..3c8d7db --- /dev/null +++ b/src/components/settings/AgentsSection.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from "react"; +import { Check, TriangleAlert } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + connectAgentClient, + detectAgentClients, + type AgentClient, + type AgentClientId, +} from "@/lib/agents"; +import { cn } from "@/lib/utils"; + +export function AgentsSection() { + const [clients, setClients] = useState(null); + const [loadError, setLoadError] = useState(null); + const [busyId, setBusyId] = useState(null); + const [rowErrors, setRowErrors] = useState>>({}); + + useEffect(() => { + detectAgentClients() + .then(setClients) + .catch((e: unknown) => setLoadError(String(e))); + }, []); + + const connect = async (id: AgentClientId) => { + setBusyId(id); + setRowErrors((prev) => ({ ...prev, [id]: undefined })); + try { + const updated = await connectAgentClient(id); + setClients((prev) => + prev ? prev.map((c) => (c.id === id ? updated : c)) : prev + ); + } catch (e: unknown) { + setRowErrors((prev) => ({ ...prev, [id]: String(e) })); + } finally { + setBusyId(null); + } + }; + + return ( +
+
+
Connect to AI agents
+
+ Register the DocsReader MCP server with agent tools installed on this + machine, so they can read and write your docs, memory, and tasks. +
+
+ + {loadError &&

{loadError}

} + {!clients && !loadError && ( +

Detecting installed agents…

+ )} + + {clients && ( +
    + {clients.map((client) => ( +
  • +
    +
    +
    {client.name}
    +
    + {client.configPath} +
    +
    + {client.detected ? ( + void connect(client.id)} + /> + ) : ( + Not detected + )} +
    + {rowErrors[client.id] && ( +

    {rowErrors[client.id]}

    + )} +
  • + ))} +
+ )} +
+ ); +} + +function ClientAction({ + client, + busy, + onConnect, +}: { + client: AgentClient; + busy: boolean; + onConnect: () => void; +}) { + if (client.status === "connected") { + return ( + + + Connected + + ); + } + const stale = client.status === "stale"; + const connectLabel = busy ? "Connecting…" : stale ? "Update" : "Connect"; + return ( +
+ {stale && ( + + + Outdated path + + )} + +
+ ); +} diff --git a/src/components/settings/SettingsDialog.test.tsx b/src/components/settings/SettingsDialog.test.tsx new file mode 100644 index 0000000..42e7a80 --- /dev/null +++ b/src/components/settings/SettingsDialog.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { defaultViewSettings } from "@/lib/storage"; +import SettingsDialog from "./SettingsDialog"; + +vi.mock("@/lib/agents", async (importOriginal) => ({ + ...(await importOriginal()), + detectAgentClients: vi.fn(), + connectAgentClient: vi.fn(), +})); + +import { detectAgentClients } from "@/lib/agents"; + +beforeEach(() => { + vi.mocked(detectAgentClients).mockResolvedValue([]); +}); + +function renderDialog() { + return render( + + ); +} + +describe("SettingsDialog", () => { + it("opens on the appearance section with all nav entries", () => { + renderDialog(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + for (const label of ["Appearance", "Reading", "Explorer", "AI agents", "Shortcuts"]) { + expect(screen.getByRole("button", { name: label })).toBeInTheDocument(); + } + expect(screen.getByRole("radio", { name: "Light" })).toBeInTheDocument(); + }); + + it("switches to the AI agents section", async () => { + renderDialog(); + await userEvent.click(screen.getByRole("button", { name: "AI agents" })); + expect(await screen.findByText("Connect to AI agents")).toBeInTheDocument(); + expect(detectAgentClients).toHaveBeenCalled(); + }); + + it("switches to the shortcuts section", async () => { + renderDialog(); + await userEvent.click(screen.getByRole("button", { name: "Shortcuts" })); + expect(screen.getByText("Quick open")).toBeInTheDocument(); + }); +}); diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx index 9a95f9c..bef485d 100644 --- a/src/components/settings/SettingsDialog.tsx +++ b/src/components/settings/SettingsDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { BookOpen, FolderTree, Keyboard, Palette } from "lucide-react"; +import { BookOpen, Bot, FolderTree, Keyboard, Palette } from "lucide-react"; import { Dialog, DialogContent, @@ -8,12 +8,21 @@ import { } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { ViewSettings } from "@/lib/storage"; +import { AgentsSection } from "./AgentsSection"; import { AppearanceSection } from "./AppearanceSection"; import { ExplorerSection } from "./ExplorerSection"; import { ReadingSection } from "./ReadingSection"; import { ShortcutsSection } from "./ShortcutsSection"; -export type SettingsSection = "appearance" | "reading" | "explorer" | "shortcuts"; +const SECTIONS = [ + { id: "appearance", label: "Appearance", icon: Palette }, + { id: "reading", label: "Reading", icon: BookOpen }, + { id: "explorer", label: "Explorer", icon: FolderTree }, + { id: "agents", label: "AI agents", icon: Bot }, + { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, +] as const; + +export type SettingsSection = (typeof SECTIONS)[number]["id"]; interface Props { open: boolean; @@ -24,15 +33,6 @@ interface Props { onOpenWelcome: () => void; } -type SectionId = SettingsSection; - -const SECTIONS: { id: SectionId; label: string; icon: typeof Palette }[] = [ - { id: "appearance", label: "Appearance", icon: Palette }, - { id: "reading", label: "Reading", icon: BookOpen }, - { id: "explorer", label: "Explorer", icon: FolderTree }, - { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, -]; - export default function SettingsDialog({ open, onOpenChange, @@ -41,7 +41,7 @@ export default function SettingsDialog({ initialSection, onOpenWelcome, }: Props) { - const [active, setActive] = useState(initialSection ?? "appearance"); + const [active, setActive] = useState(initialSection ?? "appearance"); useEffect(() => { if (open && initialSection) setActive(initialSection); @@ -57,7 +57,7 @@ export default function SettingsDialog({ Settings -
+
+ ))} + + ); +} diff --git a/src/components/document/DocumentView.tsx b/src/components/document/DocumentView.tsx index a0c8c6a..3d40da8 100644 --- a/src/components/document/DocumentView.tsx +++ b/src/components/document/DocumentView.tsx @@ -1,10 +1,13 @@ +import { Pencil } from "lucide-react"; import { cn } from "@/lib/utils"; import type { MarkdownFile } from "@/lib/scan"; import type { ViewSettings } from "@/lib/storage"; import type { Tab } from "@/hooks/useTabs"; import { MarkdownViewer } from "@/components/viewer/MarkdownViewer"; +import { Button } from "@/components/ui/button"; import { DocumentHeader } from "./DocumentHeader"; import { Frontmatter } from "./Frontmatter"; +import { QuickEditor } from "./QuickEditor"; interface Props { tab: Tab; @@ -12,12 +15,28 @@ interface Props { rootPath: string | undefined; viewSettings: ViewSettings; onNavigate: (path: string) => void; + onBeginEdit: () => void; + onDraftChange: (value: string) => void; + onCancelEdit: () => void; + onSaveEdit: () => Promise; } -export function DocumentView({ tab, file, rootPath, viewSettings, onNavigate }: Props) { +export function DocumentView({ + tab, + file, + rootPath, + viewSettings, + onNavigate, + onBeginEdit, + onDraftChange, + onCancelEdit, + onSaveEdit, +}: Props) { const title = file?.title || file?.name || tab.title; const tags = file?.tags ?? []; const modified = file?.modified; + const editing = tab.draft !== undefined; + const editable = !tab.loading && !tab.error && !editing; return (
- - +
+
+ +
+ {editable && ( + + )} +
+ {!editing && tab.draftError && ( +

{tab.draftError}

+ )} + {!editing && }
{tab.loading ? (

Loading…

) : tab.error ? (

{tab.error}

+ ) : editing ? ( + ) : ( ); }) diff --git a/src/components/document/QuickEditor.test.tsx b/src/components/document/QuickEditor.test.tsx new file mode 100644 index 0000000..ed78364 --- /dev/null +++ b/src/components/document/QuickEditor.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect } from "vitest"; +import { QuickEditor } from "./QuickEditor"; + +function setup(overrides: Partial[0]> = {}) { + const props = { + value: "# hello", + error: undefined, + onChange: vi.fn(), + onSave: vi.fn(), + onCancel: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +describe("QuickEditor", () => { + it("shows the raw source and reports edits", async () => { + const props = setup(); + const textarea = screen.getByRole("textbox", { name: "Edit markdown source" }); + expect(textarea).toHaveValue("# hello"); + await userEvent.type(textarea, "!"); + expect(props.onChange).toHaveBeenCalledWith("# hello!"); + }); + + it("saves via button and via ctrl/cmd+s", () => { + const props = setup(); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(props.onSave).toHaveBeenCalledTimes(1); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "s", metaKey: true }); + expect(props.onSave).toHaveBeenCalledTimes(2); + }); + + it("cancels via button and via escape", () => { + const props = setup(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(props.onCancel).toHaveBeenCalledTimes(2); + expect(props.onSave).not.toHaveBeenCalled(); + }); + + it("renders a save error inline", () => { + setup({ error: "permission denied" }); + expect(screen.getByText("permission denied")).toBeInTheDocument(); + }); +}); diff --git a/src/components/document/QuickEditor.tsx b/src/components/document/QuickEditor.tsx new file mode 100644 index 0000000..63f83da --- /dev/null +++ b/src/components/document/QuickEditor.tsx @@ -0,0 +1,67 @@ +import { useState, type KeyboardEvent } from "react"; +import { Button } from "@/components/ui/button"; + +interface Props { + value: string; + error: string | undefined; + onChange: (value: string) => void; + onSave: () => Promise | void; + onCancel: () => void; +} + +const isMac = navigator.platform.toUpperCase().includes("MAC"); + +export function QuickEditor({ value, error, onChange, onSave, onCancel }: Props) { + const [saving, setSaving] = useState(false); + + const save = async () => { + setSaving(true); + try { + await onSave(); + } finally { + setSaving(false); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + void save(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }; + + return ( +
+