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