Skip to main content

rustbrain_core/
note.rs

1//! Create structured Markdown notes for the brain (`note new`).
2
3use crate::error::{BrainError, Result};
4use crate::id::node_id_from_rel_path;
5use crate::types::NodeType;
6use std::path::{Path, PathBuf};
7
8/// Options for [`create_note`].
9#[derive(Debug, Clone)]
10pub struct NoteNewOptions {
11    /// Node type (`goal`, `adr`, `concept`, …).
12    pub node_type: NodeType,
13    /// Human title (also used for H1).
14    pub title: String,
15    /// Optional body text after the title (agents use `--note`).
16    pub note: Option<String>,
17    /// Optional extra tags.
18    pub tags: Vec<String>,
19    /// Optional aliases.
20    pub aliases: Vec<String>,
21    /// Subdirectory under workspace (default chosen from type).
22    pub dir: Option<PathBuf>,
23    /// If true, overwrite an existing file at the target path.
24    pub force: bool,
25    /// Optional SubBrain scope: places the note under that scope's first root
26    /// `docs/...` when `dir` is unset, and sets frontmatter `scope:` for multi-brain.
27    pub scope: Option<String>,
28}
29
30/// Result of creating a note on disk.
31#[derive(Debug, Clone)]
32pub struct NoteCreated {
33    /// Absolute path written.
34    pub path: PathBuf,
35    /// Relative path from workspace.
36    pub rel_path: PathBuf,
37    /// Stable node id after next sync.
38    pub node_id: String,
39}
40
41/// Default docs subdirectory for a node type.
42pub fn default_dir_for_type(ty: &NodeType) -> &'static str {
43    match ty {
44        NodeType::Goal => "docs/goals",
45        NodeType::Adr => "docs/adr",
46        NodeType::Alternative => "docs/adr",
47        NodeType::Concept => "docs/concepts",
48        NodeType::Analysis => "docs/analysis",
49        NodeType::Symbol => "docs/concepts",
50        NodeType::Reference => "docs/concepts",
51        NodeType::EdgeCase => "docs/edge_cases",
52        // Root Keep a Changelog file is the hub; supplementary release notes live here.
53        NodeType::Changelog => "docs/changelogs",
54        NodeType::Plan => "docs/plans",
55    }
56}
57
58/// Slugify a title into a filename stem.
59pub fn slugify_title(title: &str) -> String {
60    let mut out = String::new();
61    let mut prev_dash = false;
62    for c in title.chars() {
63        if c.is_ascii_alphanumeric() {
64            out.push(c.to_ascii_lowercase());
65            prev_dash = false;
66        } else if !prev_dash && !out.is_empty() {
67            out.push('-');
68            prev_dash = true;
69        }
70    }
71    while out.ends_with('-') {
72        out.pop();
73    }
74    if out.is_empty() {
75        "untitled".into()
76    } else {
77        out
78    }
79}
80
81/// Create a Markdown note under the workspace.
82///
83/// Does **not** open SQLite — call [`crate::Brain::sync`] afterward to index.
84pub fn create_note(workspace: &Path, opts: &NoteNewOptions) -> Result<NoteCreated> {
85    if opts.title.trim().is_empty() {
86        return Err(BrainError::other("note title must not be empty"));
87    }
88
89    let scope_id = opts
90        .scope
91        .as_deref()
92        .map(str::trim)
93        .filter(|s| !s.is_empty() && *s != crate::scopes::MAIN_SCOPE);
94
95    let dir_rel = if let Some(d) = &opts.dir {
96        d.clone()
97    } else if let Some(sid) = scope_id {
98        // Prefer first root of named SubBrain + type subdir.
99        let m = crate::scopes::load_manifest(workspace)?;
100        if let Some(sc) = m.find_scope(sid) {
101            let root = sc.roots.first().map(|r| r.as_str()).unwrap_or("");
102            PathBuf::from(root).join(default_dir_for_type(&opts.node_type))
103        } else {
104            // Imported-style default tree
105            PathBuf::from(format!(
106                "docs/subbrains/{}/{}",
107                crate::scopes::sanitize_scope_id(sid)?,
108                default_dir_for_type(&opts.node_type).trim_start_matches("docs/")
109            ))
110        }
111    } else {
112        PathBuf::from(default_dir_for_type(&opts.node_type))
113    };
114    let abs_dir = workspace.join(&dir_rel);
115    std::fs::create_dir_all(&abs_dir)?;
116
117    let stem = slugify_title(&opts.title);
118    let filename = format!("{stem}.md");
119    let abs_path = abs_dir.join(&filename);
120    let rel_path = dir_rel.join(&filename);
121
122    if abs_path.exists() && !opts.force {
123        return Err(BrainError::other(format!(
124            "note already exists: {} (use --force to overwrite)",
125            rel_path.display()
126        )));
127    }
128
129    let mut tags = opts.tags.clone();
130    if tags.is_empty() {
131        tags.push(opts.node_type.as_str().to_string());
132    }
133
134    let tags_yaml = format_yaml_list(&tags);
135    let aliases_yaml = if opts.aliases.is_empty() {
136        String::new()
137    } else {
138        format!("aliases: {}\n", format_yaml_list(&opts.aliases))
139    };
140
141    let body = opts.note.as_deref().unwrap_or("").trim();
142    let body_section = if body.is_empty() {
143        match opts.node_type {
144            NodeType::Adr => {
145                "\n## Status\n\nProposed\n\n## Context\n\n<!-- why this decision is needed -->\n\n## Decision\n\n<!-- what we decided -->\n\n## Consequences\n\n<!-- trade-offs -->\n"
146                    .to_string()
147            }
148            NodeType::Goal => {
149                "\n## Goals\n\n- \n\n## Non-goals\n\n- \n".to_string()
150            }
151            NodeType::Analysis => ANALYSIS_NOTE_TEMPLATE.to_string(),
152            NodeType::Plan => PLAN_NOTE_TEMPLATE.to_string(),
153            NodeType::Changelog => CHANGELOG_NOTE_TEMPLATE.to_string(),
154            _ => "\n".to_string(),
155        }
156    } else {
157        format!("\n{body}\n")
158    };
159
160    let status_yaml = if matches!(opts.node_type, NodeType::Plan) {
161        "status: backlog\n"
162    } else {
163        ""
164    };
165    let scope_yaml = opts
166        .scope
167        .as_deref()
168        .map(str::trim)
169        .filter(|s| !s.is_empty())
170        .map(|s| format!("scope: {s}\n"))
171        .unwrap_or_default();
172
173    let content = format!(
174        "---\n\
175         tags: {tags_yaml}\n\
176         node_type: {}\n\
177         {status_yaml}\
178         {scope_yaml}\
179         {aliases_yaml}\
180         ---\n\
181         # {}\n\
182         {body_section}",
183        opts.node_type.as_str(),
184        opts.title.trim(),
185    );
186
187    std::fs::write(&abs_path, content)?;
188
189    let node_id = node_id_from_rel_path(&rel_path);
190    Ok(NoteCreated {
191        path: abs_path,
192        rel_path,
193        node_id,
194    })
195}
196
197/// Scaffold body for empty analysis notes (episodic, not a decision).
198const ANALYSIS_NOTE_TEMPLATE: &str = r#"
199## Question / scope
200
201<!-- What are we investigating? (crate comparison, perf, design options, data, …) -->
202
203## When
204
205<!-- ISO date/time is useful in the title or here, e.g. 2026-07-31 -->
206
207## Findings
208
209-
210
211## Artifacts (optional)
212
213<!-- Links or summaries of evidence: criterion `cargo bench` output, tables, logs, PRs, plots. Prefer paths/commands over pasting huge dumps. -->
214
215-
216
217## Recommendations (optional — not a decision)
218
219<!-- Promotion path: if/when you commit, write an ADR and link it with [[…]]. -->
220
221-
222
223## Open questions / edge cases
224
225-
226
227## Related
228
229<!-- [[goals/…]]  [[concepts/…]]  symbol:Type::method  [[docs/edge_cases/…]] -->
230
231"#;
232
233/// Scaffold for plans / roadmaps / tasklists / todos (not a decision; not ship history).
234///
235/// Status vocabulary (indexed densely on sync): backlog | in_progress | qa | done |
236/// cancelled | blocked. Set overall with frontmatter `status:` and/or section headings
237/// and checkboxes (`- [ ]` open, `- [x]` done, `- [/]` in progress, `- [~]` cancelled).
238const PLAN_NOTE_TEMPLATE: &str = r#"
239## Status
240
241<!-- overall: backlog | in_progress | qa | done | cancelled | blocked -->
242in_progress
243
244## Intent
245
246<!-- What outcome does this plan unlock? Link goals: [[docs/goals/…]] -->
247
248## Backlog
249
250- [ ] 
251
252## In Progress
253
254- [/] 
255
256## QA
257
258- [?] 
259
260## Done
261
262- [x] 
263
264## Cancelled
265
266- [~] 
267
268## Blocked
269
270- [!] 
271
272## Priority / order
273
2741. 
275
276## Out of scope
277
278- 
279
280## Related
281
282<!-- [[changelog]]  [[roadmap]]  [[docs/adr/…]]  analysis notes -->
283
284"#;
285
286/// Scaffold for supplementary changelog notes (prefer root CHANGELOG.md as hub).
287const CHANGELOG_NOTE_TEMPLATE: &str = r#"
288## Version
289
290<!-- e.g. 0.4.0 — also update root CHANGELOG.md (Keep a Changelog) when shipping -->
291
292## Summary
293
294-
295
296## Added
297
298-
299
300## Changed
301
302-
303
304## Fixed
305
306-
307
308## Related
309
310<!-- Prefer the root hub: [[changelog]]  ADRs: [[docs/adr/…]] -->
311
312"#;
313
314fn format_yaml_list(items: &[String]) -> String {
315    if items.is_empty() {
316        return "[]".into();
317    }
318    let inner = items
319        .iter()
320        .map(|t| {
321            if t.chars().any(|c| c.is_whitespace() || c == ':' || c == '#') {
322                format!("\"{}\"", t.replace('"', "\\\""))
323            } else {
324                t.clone()
325            }
326        })
327        .collect::<Vec<_>>()
328        .join(", ");
329    format!("[{inner}]")
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use tempfile::tempdir;
336
337    #[test]
338    fn creates_analysis_under_docs_analysis() {
339        let dir = tempdir().unwrap();
340        let created = create_note(
341            dir.path(),
342            &NoteNewOptions {
343                node_type: NodeType::Analysis,
344                title: "Criterion bench: query path".into(),
345                note: Some(
346                    "Compared baseline vs patched run_sql. p50 improved 12% on cold cache.".into(),
347                ),
348                tags: vec!["bench".into(), "criterion".into()],
349                aliases: vec![],
350                dir: None,
351                force: false,
352                scope: None,
353            },
354        )
355        .unwrap();
356        assert!(created
357            .rel_path
358            .to_string_lossy()
359            .starts_with("docs/analysis/"));
360        let text = std::fs::read_to_string(&created.path).unwrap();
361        assert!(text.contains("node_type: analysis"));
362        assert!(text.contains("p50 improved"));
363        assert!(text.contains("# Criterion bench: query path"));
364    }
365
366    #[test]
367    fn analysis_empty_body_gets_scaffold_sections() {
368        let dir = tempdir().unwrap();
369        let created = create_note(
370            dir.path(),
371            &NoteNewOptions {
372                node_type: NodeType::Analysis,
373                title: "egui vs tauri".into(),
374                note: None,
375                tags: vec![],
376                aliases: vec![],
377                dir: None,
378                force: false,
379                scope: None,
380            },
381        )
382        .unwrap();
383        let text = std::fs::read_to_string(&created.path).unwrap();
384        assert!(text.contains("## Question / scope"));
385        assert!(text.contains("## Findings"));
386        assert!(text.contains("## Recommendations"));
387        assert!(text.contains("cargo bench"));
388    }
389
390    #[test]
391    fn plan_scaffold_under_docs_plans() {
392        let dir = tempdir().unwrap();
393        let created = create_note(
394            dir.path(),
395            &NoteNewOptions {
396                node_type: NodeType::Plan,
397                title: "Q3 roadmap".into(),
398                note: None,
399                tags: vec![],
400                aliases: vec![],
401                dir: None,
402                force: false,
403                scope: None,
404            },
405        )
406        .unwrap();
407        assert!(created
408            .rel_path
409            .to_string_lossy()
410            .starts_with("docs/plans/"));
411        let text = std::fs::read_to_string(&created.path).unwrap();
412        assert!(text.contains("node_type: plan"));
413        assert!(text.contains("status: backlog"));
414        assert!(text.contains("## Backlog"));
415        assert!(text.contains("- [ ]"));
416        assert!(text.contains("## Done"));
417    }
418
419    #[test]
420    fn creates_adr_with_body() {
421        let dir = tempdir().unwrap();
422        let created = create_note(
423            dir.path(),
424            &NoteNewOptions {
425                node_type: NodeType::Adr,
426                title: "Use SQLite".into(),
427                note: Some("We need embedded storage.".into()),
428                tags: vec!["storage".into()],
429                aliases: vec![],
430                dir: None,
431                force: false,
432                scope: None,
433            },
434        )
435        .unwrap();
436        assert!(created.path.exists());
437        let text = std::fs::read_to_string(&created.path).unwrap();
438        assert!(text.contains("node_type: adr"));
439        assert!(text.contains("# Use SQLite"));
440        assert!(text.contains("We need embedded storage."));
441        assert_eq!(created.node_id, "docs/adr/use-sqlite");
442    }
443
444    #[test]
445    fn refuses_overwrite_without_force() {
446        let dir = tempdir().unwrap();
447        let opts = NoteNewOptions {
448            node_type: NodeType::Concept,
449            title: "Dup".into(),
450            note: None,
451            tags: vec![],
452            aliases: vec![],
453            dir: None,
454            force: false,
455            scope: None,
456        };
457        create_note(dir.path(), &opts).unwrap();
458        assert!(create_note(dir.path(), &opts).is_err());
459    }
460}