Skip to main content

iwec/
lib.rs

1pub mod watcher;
2
3use std::collections::{HashMap, HashSet};
4use std::path::PathBuf;
5use std::str::FromStr;
6use std::sync::Arc;
7
8use liwe::find::{DocumentFinder, FindOptions, FindOutput};
9use liwe::query::cli::parse_projection;
10use liwe::query::{self, Filter, InclusionAnchor, ProjectionMode};
11use liwe::retrieve::{DocumentReader, RetrieveOptions, RetrieveOutput};
12use liwe::stats::{GraphStatistics, KeyStatistics};
13use liwe::fs::{new_for_path, new_from_hashmap};
14use liwe::graph::{Graph, GraphContext};
15use chrono::Local;
16use liwe::model::config::{ActionDefinition, CompletionOptions, Configuration, MarkdownOptions, NoteTemplate, DEFAULT_KEY_DATE_FORMAT};
17use liwe::model::node::{Node, NodeIter, NodePointer, Reference, ReferenceType};
18use liwe::model::tree::{Tree, TreeIter};
19use liwe::model::Key;
20use liwe::operations::{
21    delete as op_delete, extract as op_extract, inline as op_inline, rename as op_rename, Changes,
22    ExtractConfig, InlineConfig, OperationError,
23};
24use minijinja::{context, Environment};
25use rmcp::handler::server::router::prompt::PromptRouter;
26use rmcp::handler::server::router::tool::ToolRouter;
27use rmcp::handler::server::wrapper::Parameters;
28use rmcp::model::*;
29use rmcp::schemars::JsonSchema;
30use rmcp::service::RequestContext;
31use rmcp::{prompt, prompt_handler, prompt_router, tool, tool_router, RoleServer};
32use rmcp::{ErrorData as McpError, ServerHandler, tool_handler};
33use serde::{Deserialize, Serialize};
34use tokio::sync::Mutex;
35
36fn to_json_result<T: Serialize>(output: &T) -> Result<CallToolResult, McpError> {
37    let json =
38        serde_json::to_string(output).map_err(|e| McpError::internal_error(e.to_string(), None))?;
39    Ok(CallToolResult::success(vec![Content::text(json)]))
40}
41
42fn to_text_result(text: String) -> Result<CallToolResult, McpError> {
43    Ok(CallToolResult::success(vec![Content::text(text)]))
44}
45
46#[derive(Debug, Deserialize, JsonSchema)]
47#[serde(untagged)]
48pub enum KeyDepthParam {
49    Bare(String),
50    Qualified {
51        key: String,
52        depth: Option<u8>,
53    },
54}
55
56impl KeyDepthParam {
57    fn anchor(&self, default_depth: Option<u8>) -> InclusionAnchor {
58        let (key, depth) = match self {
59            KeyDepthParam::Bare(s) => (s.clone(), None),
60            KeyDepthParam::Qualified { key, depth } => (key.clone(), *depth),
61        };
62        let raw = depth.or(default_depth);
63        let max = match raw {
64            None => u32::MAX,
65            Some(0) => u32::MAX,
66            Some(n) => u32::from(n),
67        };
68        InclusionAnchor::with_max(key, max)
69    }
70}
71
72#[derive(Debug, Default, Deserialize, JsonSchema)]
73pub struct SelectorParams {
74    #[schemars(
75        description = "Restrict to candidates that are sub-documents of EVERY listed key (AND). Each entry is either a bare KEY or {key, depth}."
76    )]
77    #[serde(rename = "in", default)]
78    pub in_: Vec<KeyDepthParam>,
79    #[schemars(
80        description = "Restrict to candidates that are sub-documents of AT LEAST ONE listed key (OR)."
81    )]
82    #[serde(default)]
83    pub in_any: Vec<KeyDepthParam>,
84    #[schemars(
85        description = "Exclude candidates that are sub-documents of ANY listed key (NOT)."
86    )]
87    #[serde(default)]
88    pub not_in: Vec<KeyDepthParam>,
89    #[schemars(
90        description = "Default depth for in / in_any / not_in entries that don't specify their own depth. Omit for unbounded."
91    )]
92    #[serde(default)]
93    pub max_depth: Option<u8>,
94}
95
96impl SelectorParams {
97    pub fn is_empty(&self) -> bool {
98        self.in_.is_empty()
99            && self.in_any.is_empty()
100            && self.not_in.is_empty()
101            && self.max_depth.is_none()
102    }
103
104    pub fn to_filter(&self) -> Option<Filter> {
105        if self.is_empty() {
106            return None;
107        }
108        let mut conjuncts: Vec<Filter> = Vec::new();
109        for kd in &self.in_ {
110            conjuncts.push(Filter::IncludedBy(Box::new(kd.anchor(self.max_depth))));
111        }
112        if !self.in_any.is_empty() {
113            conjuncts.push(Filter::Or(
114                self.in_any
115                    .iter()
116                    .map(|kd| Filter::IncludedBy(Box::new(kd.anchor(self.max_depth))))
117                    .collect(),
118            ));
119        }
120        for kd in &self.not_in {
121            conjuncts.push(Filter::Nor(vec![Filter::IncludedBy(Box::new(
122                kd.anchor(self.max_depth),
123            ))]));
124        }
125        Some(Filter::And(conjuncts))
126    }
127}
128
129#[derive(Debug, Deserialize, JsonSchema)]
130pub struct FindParams {
131    #[schemars(description = "Fuzzy search query matching against document title and key")]
132    pub query: Option<String>,
133    #[schemars(description = "Only return documents that reference this key")]
134    pub refs_to: Option<String>,
135    #[schemars(description = "Only return documents referenced by this key")]
136    pub refs_from: Option<String>,
137    #[schemars(description = "Maximum number of results to return")]
138    pub limit: Option<usize>,
139    #[schemars(description = "Replacement projection (e.g. 'title,priority' or 'body=$content,parents=$includedBy'). Mutually exclusive with add_fields.")]
140    pub project: Option<String>,
141    #[schemars(description = "Additive projection: same grammar as project, extends defaults rather than replacing. Mutually exclusive with project.")]
142    pub add_fields: Option<String>,
143    #[serde(flatten)]
144    pub selector: SelectorParams,
145}
146
147impl TryFrom<FindParams> for FindOptions {
148    type Error = McpError;
149
150    fn try_from(p: FindParams) -> Result<Self, Self::Error> {
151        let project = match (p.project.as_deref(), p.add_fields.as_deref()) {
152            (Some(_), Some(_)) => {
153                return Err(McpError::invalid_params(
154                    "project and add_fields are mutually exclusive".to_string(),
155                    None,
156                ))
157            }
158            (Some(s), None) => Some(
159                parse_projection(s, ProjectionMode::Replace)
160                    .map_err(|e| McpError::invalid_params(e, None))?,
161            ),
162            (None, Some(s)) => Some(
163                parse_projection(s, ProjectionMode::Extend)
164                    .map_err(|e| McpError::invalid_params(e, None))?,
165            ),
166            (None, None) => None,
167        };
168        Ok(FindOptions {
169            query: p.query,
170            refs_to: p.refs_to.map(|k| Key::name(&k)),
171            refs_from: p.refs_from.map(|k| Key::name(&k)),
172            filter: p.selector.to_filter(),
173            limit: p.limit,
174            sort: None,
175            project,
176        })
177    }
178}
179
180#[derive(Debug, Deserialize, JsonSchema)]
181pub struct RetrieveParams {
182    #[schemars(description = "Document keys to retrieve. Can be empty when a structural selector is provided.")]
183    #[serde(default)]
184    pub keys: Vec<String>,
185    #[schemars(description = "Levels of block references to expand (0 = document only, 1 = include direct sub-documents). Default: 1")]
186    pub depth: Option<u8>,
187    #[schemars(description = "Levels of parent documents to include. Default: 1")]
188    pub context: Option<u8>,
189    #[schemars(description = "Include inline-linked documents. Default: false")]
190    pub links: Option<bool>,
191    #[schemars(description = "Include incoming inline references. Default: true")]
192    pub backlinks: Option<bool>,
193    #[schemars(description = "Document keys to exclude from results")]
194    pub exclude: Option<Vec<String>>,
195    #[schemars(description = "Return metadata only without document content. Default: false")]
196    pub no_content: Option<bool>,
197    #[schemars(description = "Populate the `includes` array with child document edges. Default: false")]
198    pub children: Option<bool>,
199    #[serde(flatten)]
200    pub selector: SelectorParams,
201}
202
203impl From<RetrieveParams> for RetrieveOptions {
204    fn from(p: RetrieveParams) -> Self {
205        RetrieveOptions {
206            depth: p.depth.unwrap_or(1),
207            context: p.context.unwrap_or(1),
208            links: p.links.unwrap_or(false),
209            backlinks: p.backlinks.unwrap_or(true),
210            exclude: p
211                .exclude
212                .unwrap_or_default()
213                .into_iter()
214                .map(|k| Key::name(&k))
215                .collect::<HashSet<_>>(),
216            no_content: p.no_content.unwrap_or(false),
217            children: p.children.unwrap_or(false),
218            filter: p.selector.to_filter(),
219        }
220    }
221}
222
223#[derive(Debug, Deserialize, JsonSchema)]
224pub struct TreeParams {
225    #[schemars(description = "Starting document keys. If empty and no selector, shows all root documents.")]
226    pub keys: Option<Vec<String>>,
227    #[schemars(description = "Maximum traversal depth. Default: 4")]
228    pub depth: Option<u8>,
229    #[serde(flatten)]
230    pub selector: SelectorParams,
231}
232
233#[derive(Debug, Serialize)]
234struct TreeNode {
235    key: String,
236    title: String,
237    children: Vec<TreeNode>,
238}
239
240#[derive(Debug, Deserialize, JsonSchema)]
241pub struct StatsParams {
242    #[schemars(description = "Document key for per-document stats. Omit for aggregate graph statistics")]
243    pub key: Option<String>,
244}
245
246#[derive(Debug, Deserialize, JsonSchema)]
247pub struct SquashParams {
248    #[schemars(description = "Root document key to expand")]
249    pub key: String,
250    #[schemars(description = "Levels of references to expand. Default: 2")]
251    pub depth: Option<u8>,
252}
253
254#[derive(Debug, Deserialize, JsonSchema)]
255pub struct CreateParams {
256    #[schemars(description = "Document title")]
257    pub title: String,
258    #[schemars(description = "Markdown content body (without the title heading)")]
259    pub content: Option<String>,
260}
261
262#[derive(Debug, Deserialize, JsonSchema)]
263pub struct UpdateParams {
264    #[schemars(description = "Document key to update")]
265    pub key: String,
266    #[schemars(description = "New full markdown content")]
267    pub content: String,
268}
269
270#[derive(Debug, Deserialize, JsonSchema)]
271pub struct DeleteParams {
272    #[schemars(description = "Document key to delete")]
273    pub key: String,
274    #[schemars(description = "Preview changes without applying. Default: false")]
275    pub dry_run: Option<bool>,
276}
277
278#[derive(Debug, Deserialize, JsonSchema)]
279pub struct RenameParams {
280    #[schemars(description = "Current document key")]
281    pub old_key: String,
282    #[schemars(description = "New document key")]
283    pub new_key: String,
284    #[schemars(description = "Preview changes without applying. Default: false")]
285    pub dry_run: Option<bool>,
286}
287
288#[derive(Debug, Serialize)]
289struct ChangesOutput {
290    creates: Vec<ChangeEntry>,
291    updates: Vec<ChangeEntry>,
292    removes: Vec<String>,
293}
294
295#[derive(Debug, Serialize)]
296struct ChangeEntry {
297    key: String,
298    content: String,
299}
300
301impl From<&Changes> for ChangesOutput {
302    fn from(c: &Changes) -> Self {
303        ChangesOutput {
304            creates: c.creates.iter().map(|(k, v)| ChangeEntry { key: k.to_string(), content: v.clone() }).collect(),
305            updates: c.updates.iter().map(|(k, v)| ChangeEntry { key: k.to_string(), content: v.clone() }).collect(),
306            removes: c.removes.iter().map(|k| k.to_string()).collect(),
307        }
308    }
309}
310
311#[derive(Debug, Deserialize, JsonSchema)]
312pub struct ExtractParams {
313    #[schemars(description = "Source document key")]
314    pub key: String,
315    #[schemars(description = "Section title to extract (case-insensitive partial match)")]
316    pub section: Option<String>,
317    #[schemars(description = "Block number to extract (1-indexed, use list mode to discover)")]
318    pub block: Option<usize>,
319    #[schemars(description = "List all sections with block numbers instead of extracting. Default: false")]
320    pub list: Option<bool>,
321    #[schemars(description = "Preview changes without applying. Default: false")]
322    pub dry_run: Option<bool>,
323}
324
325#[derive(Debug, Deserialize, JsonSchema)]
326pub struct InlineParams {
327    #[schemars(description = "Document key containing the block reference")]
328    pub key: String,
329    #[schemars(description = "Reference key or title to inline (partial match)")]
330    pub reference: Option<String>,
331    #[schemars(description = "Block number to inline (1-indexed, use list mode to discover)")]
332    pub block: Option<usize>,
333    #[schemars(description = "List all block references instead of inlining. Default: false")]
334    pub list: Option<bool>,
335    #[schemars(description = "Inline as blockquote instead of section. Default: false")]
336    pub as_quote: Option<bool>,
337    #[schemars(description = "Keep the target document after inlining. Default: false")]
338    pub keep_target: Option<bool>,
339    #[schemars(description = "Preview changes without applying. Default: false")]
340    pub dry_run: Option<bool>,
341}
342
343#[derive(Debug, Serialize)]
344struct SectionEntry {
345    block_number: usize,
346    title: String,
347}
348
349#[derive(Debug, Serialize)]
350struct ReferenceEntry {
351    block_number: usize,
352    key: String,
353    title: String,
354}
355
356#[derive(Debug, Deserialize, JsonSchema)]
357pub struct AttachParams {
358    #[schemars(description = "Configured attach action(s) to attach to (e.g. 'today'). Pass one or more action names; the source is attached under each resolved target.")]
359    #[serde(default)]
360    pub to: Vec<String>,
361    #[schemars(description = "Document key to attach as a block reference in the target(s)")]
362    pub key: Option<String>,
363    #[schemars(description = "List available attach actions instead of executing. Default: false")]
364    pub list: Option<bool>,
365    #[schemars(description = "Preview changes without applying. Default: false")]
366    pub dry_run: Option<bool>,
367}
368
369#[derive(Debug, Serialize)]
370struct AttachActionEntry {
371    name: String,
372    title: String,
373    target_key: String,
374}
375
376#[derive(Debug, Serialize)]
377struct ConfigResource {
378    markdown: MarkdownOptions,
379    library: LibraryResourceView,
380    completion: CompletionOptions,
381    templates: HashMap<String, NoteTemplate>,
382    actions: Vec<ActionResourceView>,
383}
384
385#[derive(Debug, Serialize)]
386struct LibraryResourceView {
387    date_format: Option<String>,
388    default_template: Option<String>,
389    frontmatter_document_title: Option<String>,
390    locale: Option<String>,
391}
392
393#[derive(Debug, Serialize)]
394struct ActionResourceView {
395    name: String,
396    action_type: String,
397    title: String,
398    #[serde(skip_serializing_if = "Option::is_none")]
399    target_key: Option<String>,
400}
401
402impl ConfigResource {
403    fn from_config(config: &Configuration, server: &IweServer) -> Self {
404        let actions = config
405            .actions
406            .iter()
407            .map(|(name, action)| {
408                let (action_type, title, target_key) = match action {
409                    ActionDefinition::Transform(a) => ("transform", a.title.clone(), None),
410                    ActionDefinition::Attach(a) => (
411                        "attach",
412                        a.title.clone(),
413                        Some(server.render_key_template(&a.key_template)),
414                    ),
415                    ActionDefinition::Sort(a) => ("sort", a.title.clone(), None),
416                    ActionDefinition::Inline(a) => ("inline", a.title.clone(), None),
417                    ActionDefinition::Extract(a) => ("extract", a.title.clone(), None),
418                    ActionDefinition::ExtractAll(a) => ("extract_all", a.title.clone(), None),
419                    ActionDefinition::Link(a) => ("link", a.title.clone(), None),
420                };
421                ActionResourceView {
422                    name: name.clone(),
423                    action_type: action_type.to_string(),
424                    title,
425                    target_key,
426                }
427            })
428            .collect();
429
430        Self {
431            markdown: config.markdown.clone(),
432            library: LibraryResourceView {
433                date_format: config.library.date_format.clone(),
434                default_template: config.library.default_template.clone(),
435                frontmatter_document_title: config.library.frontmatter_document_title.clone(),
436                locale: config.library.locale.clone(),
437            },
438            completion: config.completion.clone(),
439            templates: config.templates.clone(),
440            actions,
441        }
442    }
443}
444
445fn op_error_to_mcp(e: OperationError) -> McpError {
446    McpError::invalid_params(e.to_string(), None)
447}
448
449#[derive(Debug, Deserialize, JsonSchema)]
450pub struct ReviewPromptArgs {
451    #[schemars(description = "Document key to review")]
452    pub key: String,
453}
454
455#[derive(Debug, Deserialize, JsonSchema)]
456pub struct RefactorPromptArgs {
457    #[schemars(description = "Root document key to analyze for restructuring")]
458    pub key: String,
459}
460
461#[derive(Clone)]
462pub struct IweServer {
463    graph: Arc<Mutex<Graph>>,
464    base_path: Option<PathBuf>,
465    config: Configuration,
466    tool_router: ToolRouter<IweServer>,
467    prompt_router: PromptRouter<IweServer>,
468}
469
470#[tool_router]
471impl IweServer {
472    #[tool(description = "Search and discover documents in the knowledge graph. Supports fuzzy text query (`query`), root filter (`roots`), direct-reference filters (`refs_to`, `refs_from`), and the structural set selector (`in` / `in_any` / `not_in` / `max_depth`) for transitive sub-document AND/OR/NOT queries with configurable depth.")]
473    async fn iwe_find(
474        &self,
475        Parameters(params): Parameters<FindParams>,
476    ) -> Result<CallToolResult, McpError> {
477        let options: FindOptions = params.try_into()?;
478        let graph = self.graph.lock().await;
479        let finder = DocumentFinder::new(&graph);
480        let output: FindOutput = finder.find(&options);
481        to_json_result(&output.results)
482    }
483
484    #[tool(description = "Retrieve documents from the knowledge graph with configurable depth expansion, parent context, backlinks, and linked documents")]
485    async fn iwe_retrieve(
486        &self,
487        Parameters(params): Parameters<RetrieveParams>,
488    ) -> Result<CallToolResult, McpError> {
489        let graph = self.graph.lock().await;
490        let reader = DocumentReader::new(&graph);
491        let keys: Vec<Key> = params.keys.iter().map(|k| Key::name(k)).collect();
492        let options: RetrieveOptions = params.into();
493        let output: RetrieveOutput = reader.retrieve_many(&keys, &options);
494        to_json_result(&output.documents)
495    }
496
497    #[tool(description = "View the hierarchical tree structure of the knowledge graph showing how documents are connected via block references. Supports the structural set selector (in / in_any / not_in / max_depth) — when provided, the tree roots are restricted to (or selected from) that set.")]
498    async fn iwe_tree(
499        &self,
500        Parameters(params): Parameters<TreeParams>,
501    ) -> Result<CallToolResult, McpError> {
502        let graph = self.graph.lock().await;
503
504        let filter = params.selector.to_filter();
505        let explicit_keys: Vec<Key> = params
506            .keys
507            .filter(|k| !k.is_empty())
508            .map(|ks| ks.iter().map(|k| Key::name(k)).collect())
509            .unwrap_or_default();
510
511        let root_keys: Vec<Key> = if let Some(f) = filter {
512            let selector_set: HashSet<Key> =
513                query::evaluate(&f, &graph).into_iter().collect();
514            if explicit_keys.is_empty() {
515                let mut v: Vec<Key> = selector_set.into_iter().collect();
516                v.sort();
517                v
518            } else {
519                explicit_keys
520                    .into_iter()
521                    .filter(|k| selector_set.contains(k))
522                    .collect()
523            }
524        } else if !explicit_keys.is_empty() {
525            explicit_keys
526        } else {
527            let paths = graph.paths();
528            let mut keys: Vec<Key> = paths
529                .iter()
530                .filter(|n| n.ids().len() == 1)
531                .filter_map(|n| n.first_id())
532                .map(|id| (&*graph).node(id).node_key())
533                .collect();
534            keys.sort();
535            keys.dedup();
536            keys
537        };
538
539        let max_depth = params.depth.unwrap_or(4);
540        let mut trees: Vec<TreeNode> = Vec::new();
541        for root_key in &root_keys {
542            let mut visited: HashSet<Key> = HashSet::new();
543            if let Some(node) = build_tree_node(&graph, root_key, max_depth, &mut visited) {
544                trees.push(node);
545            }
546        }
547        to_json_result(&trees)
548    }
549
550    #[tool(description = "Get comprehensive statistics about the knowledge graph including document counts, reference patterns, broken links, and most connected documents")]
551    async fn iwe_stats(
552        &self,
553        Parameters(params): Parameters<StatsParams>,
554    ) -> Result<CallToolResult, McpError> {
555        let graph = self.graph.lock().await;
556        if let Some(key) = params.key {
557            let all_stats = KeyStatistics::from_graph(&graph);
558            let stat = all_stats
559                .into_iter()
560                .find(|s| s.key == key)
561                .ok_or_else(|| {
562                    McpError::invalid_params(format!("Document '{}' not found", key), None)
563                })?;
564            to_json_result(&stat)
565        } else {
566            let stats = GraphStatistics::from_graph(&graph);
567            to_json_result(&stats)
568        }
569    }
570
571    #[tool(description = "Expand all block references into a single flat markdown document. Useful for export or generating a complete view of a document tree")]
572    async fn iwe_squash(
573        &self,
574        Parameters(params): Parameters<SquashParams>,
575    ) -> Result<CallToolResult, McpError> {
576        let graph = self.graph.lock().await;
577        let key = Key::name(&params.key);
578        let depth = params.depth.unwrap_or(2);
579
580        if (&*graph).get_node_id(&key).is_none() {
581            return Err(McpError::invalid_params(
582                format!("Document '{}' not found", params.key),
583                None,
584            ));
585        }
586
587        let squashed: Tree = (&*graph).squash(&key, depth);
588        let mut patch = Graph::new();
589        patch.build_key_from_iter(&key, TreeIter::new(&squashed));
590        let content = patch.export_key(&key).unwrap_or_default();
591        to_text_result(content)
592    }
593
594    #[tool(description = "Create a new document in the knowledge graph from a title and optional content")]
595    async fn iwe_create(
596        &self,
597        Parameters(params): Parameters<CreateParams>,
598    ) -> Result<CallToolResult, McpError> {
599        let slug = params
600            .title
601            .to_lowercase()
602            .chars()
603            .map(|c| if c.is_alphanumeric() { c } else { '-' })
604            .collect::<String>()
605            .split('-')
606            .filter(|s| !s.is_empty())
607            .collect::<Vec<_>>()
608            .join("-");
609
610        let content_body = params.content.unwrap_or_default();
611        let markdown = if content_body.is_empty() {
612            format!("# {}\n", params.title)
613        } else {
614            format!("# {}\n\n{}\n", params.title, content_body)
615        };
616
617        let key = Key::name(&slug);
618        let mut graph = self.graph.lock().await;
619
620        if (&*graph).get_node_id(&key).is_some() {
621            return Err(McpError::invalid_params(
622                format!("Document '{}' already exists", slug),
623                None,
624            ));
625        }
626
627        graph.insert_document(key.clone(), markdown.clone());
628        self.write_file(&key, &markdown);
629
630        #[derive(Serialize)]
631        struct CreateResult {
632            key: String,
633        }
634        to_json_result(&CreateResult {
635            key: slug,
636        })
637    }
638
639    #[tool(description = "Update the full markdown content of an existing document")]
640    async fn iwe_update(
641        &self,
642        Parameters(params): Parameters<UpdateParams>,
643    ) -> Result<CallToolResult, McpError> {
644        let key = Key::name(&params.key);
645        let mut graph = self.graph.lock().await;
646
647        if (&*graph).get_node_id(&key).is_none() {
648            return Err(McpError::invalid_params(
649                format!("Document '{}' not found", params.key),
650                None,
651            ));
652        }
653
654        let previous_title = (&*graph)
655            .get_key_title(&key)
656            .unwrap_or_else(|| params.key.clone());
657
658        graph.update_document(key.clone(), params.content.clone());
659        self.write_file(&key, &params.content);
660
661        let new_title = (&*graph)
662            .get_key_title(&key)
663            .unwrap_or_else(|| params.key.clone());
664
665        #[derive(Serialize)]
666        struct UpdateResult {
667            key: String,
668            previous_title: String,
669            new_title: String,
670        }
671        to_json_result(&UpdateResult {
672            key: params.key,
673            previous_title,
674            new_title,
675        })
676    }
677
678    #[tool(description = "Delete a document from the knowledge graph. All block references and inline links to this document in other documents are cleaned up")]
679    async fn iwe_delete(
680        &self,
681        Parameters(params): Parameters<DeleteParams>,
682    ) -> Result<CallToolResult, McpError> {
683        let key = Key::name(&params.key);
684        let mut graph = self.graph.lock().await;
685        let changes = op_delete(&graph, &key).map_err(op_error_to_mcp)?;
686
687        if !params.dry_run.unwrap_or(false) {
688            Self::apply_changes(&mut graph, &changes);
689            self.write_changes(&changes);
690        }
691
692        to_json_result(&ChangesOutput::from(&changes))
693    }
694
695    #[tool(description = "Rename a document key. All block references and inline links across the entire graph are updated to point to the new key")]
696    async fn iwe_rename(
697        &self,
698        Parameters(params): Parameters<RenameParams>,
699    ) -> Result<CallToolResult, McpError> {
700        let old_key = Key::name(&params.old_key);
701        let new_key = Key::name(&params.new_key);
702        let mut graph = self.graph.lock().await;
703        let changes = op_rename(&graph, &old_key, &new_key).map_err(op_error_to_mcp)?;
704
705        if !params.dry_run.unwrap_or(false) {
706            Self::apply_changes(&mut graph, &changes);
707            self.write_changes(&changes);
708        }
709
710        to_json_result(&ChangesOutput::from(&changes))
711    }
712
713    #[tool(description = "Extract a section from a document into a new standalone document. The original section is replaced with a block reference. Use list mode to discover sections first")]
714    async fn iwe_extract(
715        &self,
716        Parameters(params): Parameters<ExtractParams>,
717    ) -> Result<CallToolResult, McpError> {
718        let source_key = Key::name(&params.key);
719        let mut graph = self.graph.lock().await;
720
721        if (&*graph).get_node_id(&source_key).is_none() {
722            return Err(McpError::invalid_params(
723                format!("Document '{}' not found", params.key),
724                None,
725            ));
726        }
727
728        let tree = (&*graph).collect(&source_key);
729        let sections = collect_sections(&tree);
730
731        if params.list.unwrap_or(false) {
732            return to_json_result(&sections);
733        }
734
735        let selected = if let Some(ref title) = params.section {
736            let matches: Vec<_> = sections
737                .iter()
738                .filter(|s| s.title.to_lowercase().contains(&title.to_lowercase()))
739                .collect();
740            if matches.is_empty() {
741                return Err(McpError::invalid_params(
742                    format!("No section matches '{}'", title),
743                    None,
744                ));
745            }
746            if matches.len() > 1 {
747                return Err(McpError::invalid_params(
748                    format!(
749                        "Multiple sections match '{}': {}",
750                        title,
751                        matches.iter().map(|s| s.title.as_str()).collect::<Vec<_>>().join(", ")
752                    ),
753                    None,
754                ));
755            }
756            matches[0].block_number
757        } else if let Some(block) = params.block {
758            if block == 0 || block > sections.len() {
759                return Err(McpError::invalid_params(
760                    format!("Block number {} out of range (1-{})", block, sections.len()),
761                    None,
762                ));
763            }
764            block
765        } else {
766            return Err(McpError::invalid_params(
767                "Must specify section, block, or list",
768                None,
769            ));
770        };
771
772        let section_id = tree
773            .children
774            .iter()
775            .flat_map(|c| collect_section_ids(c))
776            .nth(selected - 1)
777            .ok_or_else(|| McpError::invalid_params("Section not found", None))?;
778
779        let config = ExtractConfig::default();
780        let changes = op_extract(&graph, &source_key, section_id, &config, std::time::SystemTime::now()).map_err(op_error_to_mcp)?;
781
782        if !params.dry_run.unwrap_or(false) {
783            Self::apply_changes(&mut graph, &changes);
784            self.write_changes(&changes);
785        }
786
787        to_json_result(&ChangesOutput::from(&changes))
788    }
789
790    #[tool(description = "Replace a block reference with the actual content of the referenced document. Use list mode to discover block references first")]
791    async fn iwe_inline(
792        &self,
793        Parameters(params): Parameters<InlineParams>,
794    ) -> Result<CallToolResult, McpError> {
795        let source_key = Key::name(&params.key);
796        let mut graph = self.graph.lock().await;
797
798        if (&*graph).get_node_id(&source_key).is_none() {
799            return Err(McpError::invalid_params(
800                format!("Document '{}' not found", params.key),
801                None,
802            ));
803        }
804
805        let tree = (&*graph).collect(&source_key);
806        let refs = collect_block_refs(&tree);
807
808        if params.list.unwrap_or(false) {
809            return to_json_result(&refs);
810        }
811
812        let selected = if let Some(ref reference) = params.reference {
813            let matches: Vec<_> = refs
814                .iter()
815                .filter(|r| {
816                    r.title.to_lowercase().contains(&reference.to_lowercase())
817                        || r.key.to_lowercase().contains(&reference.to_lowercase())
818                })
819                .collect();
820            if matches.is_empty() {
821                return Err(McpError::invalid_params(
822                    format!("No reference matches '{}'", reference),
823                    None,
824                ));
825            }
826            if matches.len() > 1 {
827                return Err(McpError::invalid_params(
828                    format!(
829                        "Multiple references match '{}': {}",
830                        reference,
831                        matches.iter().map(|r| r.key.as_str()).collect::<Vec<_>>().join(", ")
832                    ),
833                    None,
834                ));
835            }
836            matches[0].block_number
837        } else if let Some(block) = params.block {
838            if block == 0 || block > refs.len() {
839                return Err(McpError::invalid_params(
840                    format!("Block number {} out of range (1-{})", block, refs.len()),
841                    None,
842                ));
843            }
844            block
845        } else {
846            return Err(McpError::invalid_params(
847                "Must specify reference, block, or list",
848                None,
849            ));
850        };
851
852        let ref_id = collect_ref_ids(&tree)
853            .into_iter()
854            .nth(selected - 1)
855            .ok_or_else(|| McpError::invalid_params("Reference not found", None))?;
856
857        let inline_type = if params.as_quote.unwrap_or(false) {
858            liwe::model::config::InlineType::Quote
859        } else {
860            liwe::model::config::InlineType::Section
861        };
862
863        let config = InlineConfig {
864            inline_type,
865            keep_target: params.keep_target.unwrap_or(false),
866        };
867
868        let changes = op_inline(&graph, &source_key, ref_id, &config).map_err(op_error_to_mcp)?;
869
870        if !params.dry_run.unwrap_or(false) {
871            Self::apply_changes(&mut graph, &changes);
872            self.write_changes(&changes);
873        }
874
875        to_json_result(&ChangesOutput::from(&changes))
876    }
877
878    #[tool(description = "Normalize all document formatting across the knowledge graph. Re-parses and re-writes all documents to ensure consistent formatting")]
879    async fn iwe_normalize(&self) -> Result<CallToolResult, McpError> {
880        let mut graph = self.graph.lock().await;
881        let state = graph.export();
882        let original_count = state.len();
883
884        let mut changed = 0usize;
885        for (key_str, original_content) in &state {
886            let key = Key::name(key_str);
887            let new_content = graph.to_markdown(&key);
888            if new_content != *original_content {
889                graph.update_document(key.clone(), new_content.clone());
890                self.write_file(&key, &new_content);
891                changed += 1;
892            }
893        }
894
895        #[derive(Serialize)]
896        struct NormalizeResult {
897            total: usize,
898            normalized: usize,
899        }
900        to_json_result(&NormalizeResult {
901            total: original_count,
902            normalized: changed,
903        })
904    }
905
906    #[tool(description = "Attach a document as a block reference in one or more target documents determined by configured attach actions. Each target key is derived from the action's key_template (e.g. daily/{{today}}). The `to` field accepts a list of action names; the source is attached under each resolved target. Targets that already contain the source are silently skipped. Use list mode to discover available attach actions.")]
907    async fn iwe_attach(
908        &self,
909        Parameters(params): Parameters<AttachParams>,
910    ) -> Result<CallToolResult, McpError> {
911        if params.list.unwrap_or(false) {
912            let entries: Vec<AttachActionEntry> = self
913                .config
914                .actions
915                .iter()
916                .filter_map(|(name, action)| {
917                    if let ActionDefinition::Attach(attach) = action {
918                        Some(AttachActionEntry {
919                            name: name.clone(),
920                            title: attach.title.clone(),
921                            target_key: self.render_key_template(&attach.key_template),
922                        })
923                    } else {
924                        None
925                    }
926                })
927                .collect();
928            return to_json_result(&entries);
929        }
930
931        if params.to.is_empty() {
932            return Err(McpError::invalid_params(
933                "'to' is required when not in list mode (pass one or more action names)".to_string(),
934                None,
935            ));
936        }
937        let source_key_str = params.key.as_deref().ok_or_else(|| {
938            McpError::invalid_params("'key' is required when not in list mode".to_string(), None)
939        })?;
940
941        let mut graph = self.graph.lock().await;
942
943        let source_key = Key::name(source_key_str);
944        if (&*graph).get_node_id(&source_key).is_none() {
945            return Err(McpError::invalid_params(
946                format!("Document '{}' not found", source_key_str),
947                None,
948            ));
949        }
950
951        let reference_text = (&*graph)
952            .get_key_title(&source_key)
953            .unwrap_or_else(|| source_key_str.to_string());
954
955        let mut combined = Changes::new();
956        let markdown_options = graph.markdown_options();
957
958        for action_name in &params.to {
959            let attach = match self.config.actions.get(action_name) {
960                Some(ActionDefinition::Attach(a)) => a,
961                Some(_) => {
962                    return Err(McpError::invalid_params(
963                        format!("Action '{}' is not an attach action", action_name),
964                        None,
965                    ));
966                }
967                None => {
968                    return Err(McpError::invalid_params(
969                        format!("Action '{}' not found", action_name),
970                        None,
971                    ));
972                }
973            };
974
975            let target_key = Key::name(&self.render_key_template(&attach.key_template));
976
977            if (&*graph).get_node_id(&target_key).is_some() {
978                let tree = (&*graph).collect(&target_key);
979                if tree
980                    .get_all_inclusion_edge_keys()
981                    .contains(&source_key)
982                {
983                    continue;
984                }
985            }
986
987            let reference = Tree {
988                id: None,
989                node: Node::Reference(Reference {
990                    key: source_key.clone(),
991                    text: reference_text.clone(),
992                    reference_type: ReferenceType::Regular,
993                }),
994                children: vec![],
995            };
996
997            if (&*graph).get_node_id(&target_key).is_some() {
998                let tree = (&*graph).collect(&target_key);
999                let updated = tree.attach(reference);
1000                combined.add_update(
1001                    target_key.clone(),
1002                    updated
1003                        .iter()
1004                        .to_markdown(&target_key.parent(), &markdown_options),
1005                );
1006            } else {
1007                let content = reference
1008                    .iter()
1009                    .to_markdown(&target_key.parent(), &markdown_options);
1010                let document = self.render_document_template(
1011                    &attach.document_template,
1012                    &content,
1013                );
1014                combined.add_create(target_key.clone(), document);
1015            }
1016        }
1017
1018        if !params.dry_run.unwrap_or(false) {
1019            Self::apply_changes(&mut graph, &combined);
1020            self.write_changes(&combined);
1021        }
1022
1023        to_json_result(&ChangesOutput::from(&combined))
1024    }
1025}
1026
1027fn build_tree_node(
1028    graph: &Graph,
1029    key: &Key,
1030    max_depth: u8,
1031    visited: &mut HashSet<Key>,
1032) -> Option<TreeNode> {
1033    graph.get_node_id(key)?;
1034
1035    let title = graph.get_ref_text(key).unwrap_or_default();
1036    let key_str = key.to_string();
1037
1038    if visited.contains(key) {
1039        return Some(TreeNode {
1040            key: key_str,
1041            title,
1042            children: vec![],
1043        });
1044    }
1045    visited.insert(key.clone());
1046
1047    let children = if max_depth > 1 {
1048        let ref_node_ids = graph.get_inclusion_edges_in(key);
1049        let mut refs: Vec<Key> = ref_node_ids
1050            .iter()
1051            .filter_map(|id| graph.graph_node(*id).ref_key())
1052            .collect();
1053        refs.sort();
1054        refs.into_iter()
1055            .filter_map(|ref_key| build_tree_node(graph, &ref_key, max_depth - 1, visited))
1056            .collect()
1057    } else {
1058        vec![]
1059    };
1060
1061    Some(TreeNode {
1062        key: key_str,
1063        title,
1064        children,
1065    })
1066}
1067
1068use liwe::model::tree::Tree as ModelTree;
1069use liwe::model::NodeId;
1070
1071fn collect_sections(tree: &ModelTree) -> Vec<SectionEntry> {
1072    let mut result = Vec::new();
1073    collect_sections_rec(tree, &mut result);
1074    result
1075}
1076
1077fn collect_sections_rec(tree: &ModelTree, sections: &mut Vec<SectionEntry>) {
1078    if let Node::Section(inlines) = &tree.node {
1079        let title = inlines.iter().map(|i| i.plain_text()).collect::<String>();
1080        sections.push(SectionEntry {
1081            block_number: sections.len() + 1,
1082            title,
1083        });
1084    }
1085    for child in &tree.children {
1086        collect_sections_rec(child, sections);
1087    }
1088}
1089
1090fn collect_section_ids(tree: &ModelTree) -> Vec<NodeId> {
1091    let mut ids = Vec::new();
1092    if tree.is_section() {
1093        if let Some(id) = tree.id {
1094            ids.push(id);
1095        }
1096    }
1097    for child in &tree.children {
1098        ids.extend(collect_section_ids(child));
1099    }
1100    ids
1101}
1102
1103fn collect_block_refs(tree: &ModelTree) -> Vec<ReferenceEntry> {
1104    let mut result = Vec::new();
1105    collect_block_refs_rec(tree, &mut result);
1106    result
1107}
1108
1109fn collect_block_refs_rec(tree: &ModelTree, refs: &mut Vec<ReferenceEntry>) {
1110    if let Node::Reference(reference) = &tree.node {
1111        refs.push(ReferenceEntry {
1112            block_number: refs.len() + 1,
1113            key: reference.key.to_string(),
1114            title: reference.text.clone(),
1115        });
1116    }
1117    for child in &tree.children {
1118        collect_block_refs_rec(child, refs);
1119    }
1120}
1121
1122fn collect_ref_ids(tree: &ModelTree) -> Vec<NodeId> {
1123    let mut ids = Vec::new();
1124    if let Node::Reference(_) = &tree.node {
1125        if let Some(id) = tree.id {
1126            ids.push(id);
1127        }
1128    }
1129    for child in &tree.children {
1130        ids.extend(collect_ref_ids(child));
1131    }
1132    ids
1133}
1134
1135#[prompt_router]
1136impl IweServer {
1137    #[prompt(
1138        name = "explore",
1139        description = "Start exploring the knowledge graph. Provides an overview of size, structure, root entry points, broken links, and orphaned documents"
1140    )]
1141    async fn explore(&self) -> Result<GetPromptResult, McpError> {
1142        let graph = self.graph.lock().await;
1143        let stats = GraphStatistics::from_graph(&graph);
1144        let stats_json =
1145            serde_json::to_string_pretty(&stats).unwrap_or_else(|_| "{}".to_string());
1146
1147        let messages = vec![PromptMessage::new_text(
1148            PromptMessageRole::User,
1149            format!(
1150                "Here is an overview of the IWE knowledge graph.\n\n## Statistics\n\n```json\n{}\n```\n\nExplore the graph using iwe_retrieve to read documents, iwe_find to search, and iwe_tree to navigate the structure.",
1151                stats_json
1152            ),
1153        )];
1154
1155        Ok(GetPromptResult::new(messages)
1156            .with_description("Overview of the IWE knowledge graph"))
1157    }
1158
1159    #[prompt(
1160        name = "review",
1161        description = "Review a specific document within its graph context — its content, parents, children, and backlinks"
1162    )]
1163    async fn review(
1164        &self,
1165        Parameters(args): Parameters<ReviewPromptArgs>,
1166    ) -> Result<GetPromptResult, McpError> {
1167        let graph = self.graph.lock().await;
1168        let key = Key::name(&args.key);
1169        let reader = DocumentReader::new(&graph);
1170        let output = reader.retrieve(
1171            &key,
1172            &RetrieveOptions {
1173                depth: 2,
1174                context: 2,
1175                backlinks: true,
1176                ..Default::default()
1177            },
1178        );
1179        let json = serde_json::to_string_pretty(&output.documents)
1180            .unwrap_or_else(|_| "[]".to_string());
1181
1182        let messages = vec![PromptMessage::new_text(
1183            PromptMessageRole::User,
1184            format!(
1185                "Review this document and its context in the knowledge graph:\n\n```json\n{}\n```\n\nConsider: Is it well-placed in the graph? Are there missing links? Is the content clear and well-structured? What sections might be extracted into separate documents?",
1186                json
1187            ),
1188        )];
1189
1190        Ok(GetPromptResult::new(messages)
1191            .with_description(format!("Review of document '{}'", args.key)))
1192    }
1193
1194    #[prompt(
1195        name = "refactor",
1196        description = "Analyze a section of the knowledge graph and suggest restructuring using extract, inline, and rename operations"
1197    )]
1198    async fn refactor(
1199        &self,
1200        Parameters(args): Parameters<RefactorPromptArgs>,
1201    ) -> Result<GetPromptResult, McpError> {
1202        let graph = self.graph.lock().await;
1203        let key = Key::name(&args.key);
1204        let reader = DocumentReader::new(&graph);
1205        let output = reader.retrieve(
1206            &key,
1207            &RetrieveOptions {
1208                depth: 3,
1209                context: 1,
1210                backlinks: true,
1211                ..Default::default()
1212            },
1213        );
1214        let json = serde_json::to_string_pretty(&output.documents)
1215            .unwrap_or_else(|_| "[]".to_string());
1216
1217        let messages = vec![PromptMessage::new_text(
1218            PromptMessageRole::User,
1219            format!(
1220                "Analyze this document tree and suggest restructuring:\n\n```json\n{}\n```\n\nIdentify documents that are too large (should be extracted with iwe_extract), too small (should be inlined with iwe_inline), poorly named (should be renamed with iwe_rename), or missing connections. Propose a sequence of operations to improve the structure.",
1221                json
1222            ),
1223        )];
1224
1225        Ok(GetPromptResult::new(messages)
1226            .with_description(format!("Refactoring analysis for '{}'", args.key)))
1227    }
1228}
1229
1230#[tool_handler]
1231#[prompt_handler]
1232impl ServerHandler for IweServer {
1233    fn get_info(&self) -> ServerInfo {
1234        ServerInfo::new(
1235            ServerCapabilities::builder()
1236                .enable_tools()
1237                .enable_prompts()
1238                .enable_resources()
1239                .build(),
1240        )
1241        .with_server_info(Implementation::new("iwe", env!("CARGO_PKG_VERSION")))
1242        .with_instructions(
1243            "IWE knowledge graph server. Tools: iwe_find, iwe_retrieve, iwe_tree, iwe_stats, iwe_squash, iwe_create, iwe_update, iwe_delete, iwe_rename, iwe_extract, iwe_inline, iwe_normalize, iwe_attach. Prompts: explore, review, refactor. Resources: iwe://documents/{key}, iwe://tree, iwe://stats, iwe://config."
1244                .to_string(),
1245        )
1246    }
1247
1248    async fn list_resources(
1249        &self,
1250        _request: Option<PaginatedRequestParams>,
1251        _: RequestContext<RoleServer>,
1252    ) -> Result<ListResourcesResult, McpError> {
1253        let graph = self.graph.lock().await;
1254        let mut resources = vec![
1255            RawResource::new("iwe://tree", "tree")
1256                .with_description("Full document tree structure")
1257                .with_mime_type("application/json")
1258                .no_annotation(),
1259            RawResource::new("iwe://stats", "stats")
1260                .with_description("Aggregate graph statistics")
1261                .with_mime_type("application/json")
1262                .no_annotation(),
1263            RawResource::new("iwe://config", "config")
1264                .with_description("Project configuration: markdown options, templates, actions")
1265                .with_mime_type("application/json")
1266                .no_annotation(),
1267        ];
1268
1269        for key in graph.keys().iter().take(100) {
1270            let title = (&*graph)
1271                .get_key_title(key)
1272                .unwrap_or_else(|| key.to_string());
1273            resources.push(
1274                RawResource::new(format!("iwe://documents/{}", key), title)
1275                    .with_mime_type("text/markdown")
1276                    .no_annotation(),
1277            );
1278        }
1279
1280        Ok(ListResourcesResult {
1281            resources,
1282            next_cursor: None,
1283            meta: None,
1284        })
1285    }
1286
1287    async fn read_resource(
1288        &self,
1289        request: ReadResourceRequestParams,
1290        _: RequestContext<RoleServer>,
1291    ) -> Result<ReadResourceResult, McpError> {
1292        let uri = &request.uri;
1293        let graph = self.graph.lock().await;
1294
1295        if uri == "iwe://tree" {
1296            let paths = graph.paths();
1297            let mut root_keys: Vec<Key> = paths
1298                .iter()
1299                .filter(|n| n.ids().len() == 1)
1300                .filter_map(|n| n.first_id())
1301                .map(|id| (&*graph).node(id).node_key())
1302                .collect();
1303            root_keys.sort();
1304            root_keys.dedup();
1305
1306            let mut trees: Vec<TreeNode> = Vec::new();
1307            for root_key in &root_keys {
1308                let mut visited: HashSet<Key> = HashSet::new();
1309                if let Some(node) = build_tree_node(&graph, root_key, 4, &mut visited) {
1310                    trees.push(node);
1311                }
1312            }
1313            let json = serde_json::to_string_pretty(&trees)
1314                .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1315            return Ok(ReadResourceResult::new(vec![ResourceContents::text(
1316                json,
1317                uri.clone(),
1318            )]));
1319        }
1320
1321        if uri == "iwe://stats" {
1322            let stats = GraphStatistics::from_graph(&graph);
1323            let json = serde_json::to_string_pretty(&stats)
1324                .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1325            return Ok(ReadResourceResult::new(vec![ResourceContents::text(
1326                json,
1327                uri.clone(),
1328            )]));
1329        }
1330
1331        if uri == "iwe://config" {
1332            let config_view = ConfigResource::from_config(&self.config, self);
1333            let json = serde_json::to_string_pretty(&config_view)
1334                .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1335            return Ok(ReadResourceResult::new(vec![ResourceContents::text(
1336                json,
1337                uri.clone(),
1338            )]));
1339        }
1340
1341        if let Some(key_str) = uri.strip_prefix("iwe://documents/") {
1342            let key = Key::name(key_str);
1343            let content = graph
1344                .get_document(&key)
1345                .ok_or_else(|| {
1346                    McpError::resource_not_found(
1347                        format!("Document '{}' not found", key_str),
1348                        None,
1349                    )
1350                })?
1351                .to_string();
1352            return Ok(ReadResourceResult::new(vec![ResourceContents::text(
1353                content,
1354                uri.clone(),
1355            )]));
1356        }
1357
1358        Err(McpError::resource_not_found(
1359            format!("Unknown resource: {}", uri),
1360            None,
1361        ))
1362    }
1363
1364    async fn list_resource_templates(
1365        &self,
1366        _request: Option<PaginatedRequestParams>,
1367        _: RequestContext<RoleServer>,
1368    ) -> Result<ListResourceTemplatesResult, McpError> {
1369        Ok(ListResourceTemplatesResult {
1370            resource_templates: vec![
1371                RawResourceTemplate::new("iwe://documents/{key}", "document")
1372                    .with_description("A document in the knowledge graph by key")
1373                    .with_mime_type("text/markdown")
1374                    .no_annotation(),
1375            ],
1376            next_cursor: None,
1377            meta: None,
1378        })
1379    }
1380}
1381
1382impl IweServer {
1383    pub fn new(base_path: &str, configuration: &Configuration) -> Self {
1384        let path = PathBuf::from_str(base_path).expect("valid path");
1385        let state = new_for_path(&path);
1386        let graph = Graph::from_state(
1387            &state,
1388            false,
1389            configuration.markdown.clone().into(),
1390            configuration.library.frontmatter_document_title.clone(),
1391        );
1392        Self {
1393            graph: Arc::new(Mutex::new(graph)),
1394            base_path: Some(path),
1395            config: configuration.clone(),
1396            tool_router: Self::tool_router(),
1397            prompt_router: Self::prompt_router(),
1398        }
1399    }
1400
1401    pub fn from_documents(documents: Vec<(&str, &str)>) -> Self {
1402        Self::from_documents_with_config(documents, Configuration::default())
1403    }
1404
1405    pub fn from_documents_with_config(documents: Vec<(&str, &str)>, config: Configuration) -> Self {
1406        let state = new_from_hashmap(
1407            documents
1408                .into_iter()
1409                .map(|(k, v)| (k.to_string(), v.to_string()))
1410                .collect::<HashMap<String, String>>(),
1411        );
1412        let graph = Graph::from_state(&state, true, MarkdownOptions::default(), None);
1413        Self {
1414            graph: Arc::new(Mutex::new(graph)),
1415            base_path: None,
1416            config,
1417            tool_router: Self::tool_router(),
1418            prompt_router: Self::prompt_router(),
1419        }
1420    }
1421
1422    fn apply_changes(graph: &mut Graph, changes: &Changes) {
1423        for key in &changes.removes {
1424            graph.remove_document(key.clone());
1425        }
1426        for (key, markdown) in &changes.creates {
1427            graph.insert_document(key.clone(), markdown.clone());
1428        }
1429        for (key, markdown) in &changes.updates {
1430            graph.update_document(key.clone(), markdown.clone());
1431        }
1432    }
1433
1434    fn write_file(&self, key: &Key, content: &str) {
1435        if let Some(base_path) = &self.base_path {
1436            let file_path = base_path.join(format!("{}.md", key));
1437            if let Some(parent) = file_path.parent() {
1438                std::fs::create_dir_all(parent).ok();
1439            }
1440            std::fs::write(&file_path, content).ok();
1441        }
1442    }
1443
1444    fn write_changes(&self, changes: &Changes) {
1445        if let Some(base_path) = &self.base_path {
1446            for key in &changes.removes {
1447                let file_path = base_path.join(format!("{}.md", key));
1448                if file_path.exists() {
1449                    std::fs::remove_file(&file_path).ok();
1450                }
1451            }
1452            for (key, markdown) in &changes.creates {
1453                let file_path = base_path.join(format!("{}.md", key));
1454                if let Some(parent) = file_path.parent() {
1455                    std::fs::create_dir_all(parent).ok();
1456                }
1457                std::fs::write(&file_path, markdown).ok();
1458            }
1459            for (key, markdown) in &changes.updates {
1460                let file_path = base_path.join(format!("{}.md", key));
1461                std::fs::write(&file_path, markdown).ok();
1462            }
1463        }
1464    }
1465
1466    pub fn start_watching(&self) {
1467        if let Some(base_path) = &self.base_path {
1468            watcher::start(self.graph.clone(), base_path.clone());
1469        }
1470    }
1471
1472    fn render_key_template(&self, template: &str) -> String {
1473        let now = Local::now();
1474        let date_format = self
1475            .config
1476            .library
1477            .date_format
1478            .as_deref()
1479            .unwrap_or(DEFAULT_KEY_DATE_FORMAT);
1480        let formatted = now.format(date_format).to_string();
1481        Environment::new()
1482            .template_from_str(template)
1483            .expect("valid key template")
1484            .render(context! {
1485                today => formatted,
1486                now => formatted,
1487            })
1488            .expect("key template to render")
1489    }
1490
1491    fn render_document_template(&self, template: &str, content: &str) -> String {
1492        let now = Local::now();
1493        let date_format = self
1494            .config
1495            .markdown
1496            .date_format
1497            .as_deref()
1498            .unwrap_or("%b %d, %Y");
1499        let formatted = now.format(date_format).to_string();
1500        Environment::new()
1501            .template_from_str(template)
1502            .expect("valid document template")
1503            .render(context! {
1504                today => formatted,
1505                now => formatted,
1506                content => content,
1507            })
1508            .expect("document template to render")
1509    }
1510}