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::Symbol => "docs/concepts",
46        NodeType::Reference => "docs/concepts",
47        NodeType::EdgeCase => "docs/edge_cases",
48    }
49}
50
51/// Slugify a title into a filename stem.
52pub fn slugify_title(title: &str) -> String {
53    let mut out = String::new();
54    let mut prev_dash = false;
55    for c in title.chars() {
56        if c.is_ascii_alphanumeric() {
57            out.push(c.to_ascii_lowercase());
58            prev_dash = false;
59        } else if !prev_dash && !out.is_empty() {
60            out.push('-');
61            prev_dash = true;
62        }
63    }
64    while out.ends_with('-') {
65        out.pop();
66    }
67    if out.is_empty() {
68        "untitled".into()
69    } else {
70        out
71    }
72}
73
74/// Create a Markdown note under the workspace.
75///
76/// Does **not** open SQLite — call [`crate::Brain::sync`] afterward to index.
77pub fn create_note(workspace: &Path, opts: &NoteNewOptions) -> Result<NoteCreated> {
78    if opts.title.trim().is_empty() {
79        return Err(BrainError::other("note title must not be empty"));
80    }
81
82    let dir_rel = opts
83        .dir
84        .clone()
85        .unwrap_or_else(|| PathBuf::from(default_dir_for_type(&opts.node_type)));
86    let abs_dir = workspace.join(&dir_rel);
87    std::fs::create_dir_all(&abs_dir)?;
88
89    let stem = slugify_title(&opts.title);
90    let filename = format!("{stem}.md");
91    let abs_path = abs_dir.join(&filename);
92    let rel_path = dir_rel.join(&filename);
93
94    if abs_path.exists() && !opts.force {
95        return Err(BrainError::other(format!(
96            "note already exists: {} (use --force to overwrite)",
97            rel_path.display()
98        )));
99    }
100
101    let mut tags = opts.tags.clone();
102    if tags.is_empty() {
103        tags.push(opts.node_type.as_str().to_string());
104    }
105
106    let tags_yaml = format_yaml_list(&tags);
107    let aliases_yaml = if opts.aliases.is_empty() {
108        String::new()
109    } else {
110        format!("aliases: {}\n", format_yaml_list(&opts.aliases))
111    };
112
113    let body = opts.note.as_deref().unwrap_or("").trim();
114    let body_section = if body.is_empty() {
115        match opts.node_type {
116            NodeType::Adr => {
117                "\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"
118                    .to_string()
119            }
120            NodeType::Goal => {
121                "\n## Goals\n\n- \n\n## Non-goals\n\n- \n".to_string()
122            }
123            _ => "\n".to_string(),
124        }
125    } else {
126        format!("\n{body}\n")
127    };
128
129    let content = format!(
130        "---\n\
131         tags: {tags_yaml}\n\
132         node_type: {}\n\
133         {aliases_yaml}\
134         ---\n\
135         # {}\n\
136         {body_section}",
137        opts.node_type.as_str(),
138        opts.title.trim(),
139    );
140
141    std::fs::write(&abs_path, content)?;
142
143    let node_id = node_id_from_rel_path(&rel_path);
144    Ok(NoteCreated {
145        path: abs_path,
146        rel_path,
147        node_id,
148    })
149}
150
151fn format_yaml_list(items: &[String]) -> String {
152    if items.is_empty() {
153        return "[]".into();
154    }
155    let inner = items
156        .iter()
157        .map(|t| {
158            if t.chars().any(|c| c.is_whitespace() || c == ':' || c == '#') {
159                format!("\"{}\"", t.replace('"', "\\\""))
160            } else {
161                t.clone()
162            }
163        })
164        .collect::<Vec<_>>()
165        .join(", ");
166    format!("[{inner}]")
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use tempfile::tempdir;
173
174    #[test]
175    fn creates_adr_with_body() {
176        let dir = tempdir().unwrap();
177        let created = create_note(
178            dir.path(),
179            &NoteNewOptions {
180                node_type: NodeType::Adr,
181                title: "Use SQLite".into(),
182                note: Some("We need embedded storage.".into()),
183                tags: vec!["storage".into()],
184                aliases: vec![],
185                dir: None,
186                force: false,
187            },
188        )
189        .unwrap();
190        assert!(created.path.exists());
191        let text = std::fs::read_to_string(&created.path).unwrap();
192        assert!(text.contains("node_type: adr"));
193        assert!(text.contains("# Use SQLite"));
194        assert!(text.contains("We need embedded storage."));
195        assert_eq!(created.node_id, "docs/adr/use-sqlite");
196    }
197
198    #[test]
199    fn refuses_overwrite_without_force() {
200        let dir = tempdir().unwrap();
201        let opts = NoteNewOptions {
202            node_type: NodeType::Concept,
203            title: "Dup".into(),
204            note: None,
205            tags: vec![],
206            aliases: vec![],
207            dir: None,
208            force: false,
209        };
210        create_note(dir.path(), &opts).unwrap();
211        assert!(create_note(dir.path(), &opts).is_err());
212    }
213}