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}
26
27/// Result of creating a note on disk.
28#[derive(Debug, Clone)]
29pub struct NoteCreated {
30    /// Absolute path written.
31    pub path: PathBuf,
32    /// Relative path from workspace.
33    pub rel_path: PathBuf,
34    /// Stable node id after next sync.
35    pub node_id: String,
36}
37
38/// Default docs subdirectory for a node type.
39pub fn default_dir_for_type(ty: &NodeType) -> &'static str {
40    match ty {
41        NodeType::Goal => "docs/goals",
42        NodeType::Adr => "docs/adr",
43        NodeType::Alternative => "docs/adr",
44        NodeType::Concept => "docs/concepts",
45        NodeType::Analysis => "docs/analysis",
46        NodeType::Symbol => "docs/concepts",
47        NodeType::Reference => "docs/concepts",
48        NodeType::EdgeCase => "docs/edge_cases",
49    }
50}
51
52/// Slugify a title into a filename stem.
53pub fn slugify_title(title: &str) -> String {
54    let mut out = String::new();
55    let mut prev_dash = false;
56    for c in title.chars() {
57        if c.is_ascii_alphanumeric() {
58            out.push(c.to_ascii_lowercase());
59            prev_dash = false;
60        } else if !prev_dash && !out.is_empty() {
61            out.push('-');
62            prev_dash = true;
63        }
64    }
65    while out.ends_with('-') {
66        out.pop();
67    }
68    if out.is_empty() {
69        "untitled".into()
70    } else {
71        out
72    }
73}
74
75/// Create a Markdown note under the workspace.
76///
77/// Does **not** open SQLite — call [`crate::Brain::sync`] afterward to index.
78pub fn create_note(workspace: &Path, opts: &NoteNewOptions) -> Result<NoteCreated> {
79    if opts.title.trim().is_empty() {
80        return Err(BrainError::other("note title must not be empty"));
81    }
82
83    let dir_rel = opts
84        .dir
85        .clone()
86        .unwrap_or_else(|| PathBuf::from(default_dir_for_type(&opts.node_type)));
87    let abs_dir = workspace.join(&dir_rel);
88    std::fs::create_dir_all(&abs_dir)?;
89
90    let stem = slugify_title(&opts.title);
91    let filename = format!("{stem}.md");
92    let abs_path = abs_dir.join(&filename);
93    let rel_path = dir_rel.join(&filename);
94
95    if abs_path.exists() && !opts.force {
96        return Err(BrainError::other(format!(
97            "note already exists: {} (use --force to overwrite)",
98            rel_path.display()
99        )));
100    }
101
102    let mut tags = opts.tags.clone();
103    if tags.is_empty() {
104        tags.push(opts.node_type.as_str().to_string());
105    }
106
107    let tags_yaml = format_yaml_list(&tags);
108    let aliases_yaml = if opts.aliases.is_empty() {
109        String::new()
110    } else {
111        format!("aliases: {}\n", format_yaml_list(&opts.aliases))
112    };
113
114    let body = opts.note.as_deref().unwrap_or("").trim();
115    let body_section = if body.is_empty() {
116        match opts.node_type {
117            NodeType::Adr => {
118                "\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"
119                    .to_string()
120            }
121            NodeType::Goal => {
122                "\n## Goals\n\n- \n\n## Non-goals\n\n- \n".to_string()
123            }
124            NodeType::Analysis => ANALYSIS_NOTE_TEMPLATE.to_string(),
125            _ => "\n".to_string(),
126        }
127    } else {
128        format!("\n{body}\n")
129    };
130
131    let content = format!(
132        "---\n\
133         tags: {tags_yaml}\n\
134         node_type: {}\n\
135         {aliases_yaml}\
136         ---\n\
137         # {}\n\
138         {body_section}",
139        opts.node_type.as_str(),
140        opts.title.trim(),
141    );
142
143    std::fs::write(&abs_path, content)?;
144
145    let node_id = node_id_from_rel_path(&rel_path);
146    Ok(NoteCreated {
147        path: abs_path,
148        rel_path,
149        node_id,
150    })
151}
152
153/// Scaffold body for empty analysis notes (episodic, not a decision).
154const ANALYSIS_NOTE_TEMPLATE: &str = r#"
155## Question / scope
156
157<!-- What are we investigating? (crate comparison, perf, design options, data, …) -->
158
159## When
160
161<!-- ISO date/time is useful in the title or here, e.g. 2026-07-31 -->
162
163## Findings
164
165-
166
167## Artifacts (optional)
168
169<!-- Links or summaries of evidence: criterion `cargo bench` output, tables, logs, PRs, plots. Prefer paths/commands over pasting huge dumps. -->
170
171-
172
173## Recommendations (optional — not a decision)
174
175<!-- Promotion path: if/when you commit, write an ADR and link it with [[…]]. -->
176
177-
178
179## Open questions / edge cases
180
181-
182
183## Related
184
185<!-- [[goals/…]]  [[concepts/…]]  symbol:Type::method  [[docs/edge_cases/…]] -->
186
187"#;
188
189fn format_yaml_list(items: &[String]) -> String {
190    if items.is_empty() {
191        return "[]".into();
192    }
193    let inner = items
194        .iter()
195        .map(|t| {
196            if t.chars().any(|c| c.is_whitespace() || c == ':' || c == '#') {
197                format!("\"{}\"", t.replace('"', "\\\""))
198            } else {
199                t.clone()
200            }
201        })
202        .collect::<Vec<_>>()
203        .join(", ");
204    format!("[{inner}]")
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use tempfile::tempdir;
211
212    #[test]
213    fn creates_analysis_under_docs_analysis() {
214        let dir = tempdir().unwrap();
215        let created = create_note(
216            dir.path(),
217            &NoteNewOptions {
218                node_type: NodeType::Analysis,
219                title: "Criterion bench: query path".into(),
220                note: Some(
221                    "Compared baseline vs patched run_sql. p50 improved 12% on cold cache.".into(),
222                ),
223                tags: vec!["bench".into(), "criterion".into()],
224                aliases: vec![],
225                dir: None,
226                force: false,
227            },
228        )
229        .unwrap();
230        assert!(created
231            .rel_path
232            .to_string_lossy()
233            .starts_with("docs/analysis/"));
234        let text = std::fs::read_to_string(&created.path).unwrap();
235        assert!(text.contains("node_type: analysis"));
236        assert!(text.contains("p50 improved"));
237        assert!(text.contains("# Criterion bench: query path"));
238    }
239
240    #[test]
241    fn analysis_empty_body_gets_scaffold_sections() {
242        let dir = tempdir().unwrap();
243        let created = create_note(
244            dir.path(),
245            &NoteNewOptions {
246                node_type: NodeType::Analysis,
247                title: "egui vs tauri".into(),
248                note: None,
249                tags: vec![],
250                aliases: vec![],
251                dir: None,
252                force: false,
253            },
254        )
255        .unwrap();
256        let text = std::fs::read_to_string(&created.path).unwrap();
257        assert!(text.contains("## Question / scope"));
258        assert!(text.contains("## Findings"));
259        assert!(text.contains("## Recommendations"));
260        assert!(text.contains("cargo bench"));
261    }
262
263    #[test]
264    fn creates_adr_with_body() {
265        let dir = tempdir().unwrap();
266        let created = create_note(
267            dir.path(),
268            &NoteNewOptions {
269                node_type: NodeType::Adr,
270                title: "Use SQLite".into(),
271                note: Some("We need embedded storage.".into()),
272                tags: vec!["storage".into()],
273                aliases: vec![],
274                dir: None,
275                force: false,
276            },
277        )
278        .unwrap();
279        assert!(created.path.exists());
280        let text = std::fs::read_to_string(&created.path).unwrap();
281        assert!(text.contains("node_type: adr"));
282        assert!(text.contains("# Use SQLite"));
283        assert!(text.contains("We need embedded storage."));
284        assert_eq!(created.node_id, "docs/adr/use-sqlite");
285    }
286
287    #[test]
288    fn refuses_overwrite_without_force() {
289        let dir = tempdir().unwrap();
290        let opts = NoteNewOptions {
291            node_type: NodeType::Concept,
292            title: "Dup".into(),
293            note: None,
294            tags: vec![],
295            aliases: vec![],
296            dir: None,
297            force: false,
298        };
299        create_note(dir.path(), &opts).unwrap();
300        assert!(create_note(dir.path(), &opts).is_err());
301    }
302}