1use crate::error::{BrainError, Result};
4use crate::id::node_id_from_rel_path;
5use crate::types::NodeType;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone)]
10pub struct NoteNewOptions {
11 pub node_type: NodeType,
13 pub title: String,
15 pub note: Option<String>,
17 pub tags: Vec<String>,
19 pub aliases: Vec<String>,
21 pub dir: Option<PathBuf>,
23 pub force: bool,
25}
26
27#[derive(Debug, Clone)]
29pub struct NoteCreated {
30 pub path: PathBuf,
32 pub rel_path: PathBuf,
34 pub node_id: String,
36}
37
38pub 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
52pub 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
75pub 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
153const 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}