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 NodeType::Changelog => "docs/changelogs",
51 NodeType::Plan => "docs/plans",
52 }
53}
54
55pub 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
78pub 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 content = format!(
137 "---\n\
138 tags: {tags_yaml}\n\
139 node_type: {}\n\
140 {aliases_yaml}\
141 ---\n\
142 # {}\n\
143 {body_section}",
144 opts.node_type.as_str(),
145 opts.title.trim(),
146 );
147
148 std::fs::write(&abs_path, content)?;
149
150 let node_id = node_id_from_rel_path(&rel_path);
151 Ok(NoteCreated {
152 path: abs_path,
153 rel_path,
154 node_id,
155 })
156}
157
158const ANALYSIS_NOTE_TEMPLATE: &str = r#"
160## Question / scope
161
162<!-- What are we investigating? (crate comparison, perf, design options, data, …) -->
163
164## When
165
166<!-- ISO date/time is useful in the title or here, e.g. 2026-07-31 -->
167
168## Findings
169
170-
171
172## Artifacts (optional)
173
174<!-- Links or summaries of evidence: criterion `cargo bench` output, tables, logs, PRs, plots. Prefer paths/commands over pasting huge dumps. -->
175
176-
177
178## Recommendations (optional — not a decision)
179
180<!-- Promotion path: if/when you commit, write an ADR and link it with [[…]]. -->
181
182-
183
184## Open questions / edge cases
185
186-
187
188## Related
189
190<!-- [[goals/…]] [[concepts/…]] symbol:Type::method [[docs/edge_cases/…]] -->
191
192"#;
193
194const PLAN_NOTE_TEMPLATE: &str = r#"
196## Status
197
198<!-- draft | active | done | deferred — free-form, not a kanban product -->
199
200## Intent
201
202<!-- What outcome does this plan unlock? Link goals: [[docs/goals/…]] -->
203
204## Items
205
206- [ ]
207- [ ]
208
209## Priority / order
210
2111.
212
213## Out of scope
214
215-
216
217## Related
218
219<!-- [[changelog]] [[roadmap]] [[docs/adr/…]] analysis notes -->
220
221"#;
222
223const CHANGELOG_NOTE_TEMPLATE: &str = r#"
225## Version
226
227<!-- e.g. 0.4.0 — also update root CHANGELOG.md (Keep a Changelog) when shipping -->
228
229## Summary
230
231-
232
233## Added
234
235-
236
237## Changed
238
239-
240
241## Fixed
242
243-
244
245## Related
246
247<!-- Prefer the root hub: [[changelog]] ADRs: [[docs/adr/…]] -->
248
249"#;
250
251fn format_yaml_list(items: &[String]) -> String {
252 if items.is_empty() {
253 return "[]".into();
254 }
255 let inner = items
256 .iter()
257 .map(|t| {
258 if t.chars().any(|c| c.is_whitespace() || c == ':' || c == '#') {
259 format!("\"{}\"", t.replace('"', "\\\""))
260 } else {
261 t.clone()
262 }
263 })
264 .collect::<Vec<_>>()
265 .join(", ");
266 format!("[{inner}]")
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use tempfile::tempdir;
273
274 #[test]
275 fn creates_analysis_under_docs_analysis() {
276 let dir = tempdir().unwrap();
277 let created = create_note(
278 dir.path(),
279 &NoteNewOptions {
280 node_type: NodeType::Analysis,
281 title: "Criterion bench: query path".into(),
282 note: Some(
283 "Compared baseline vs patched run_sql. p50 improved 12% on cold cache.".into(),
284 ),
285 tags: vec!["bench".into(), "criterion".into()],
286 aliases: vec![],
287 dir: None,
288 force: false,
289 },
290 )
291 .unwrap();
292 assert!(created
293 .rel_path
294 .to_string_lossy()
295 .starts_with("docs/analysis/"));
296 let text = std::fs::read_to_string(&created.path).unwrap();
297 assert!(text.contains("node_type: analysis"));
298 assert!(text.contains("p50 improved"));
299 assert!(text.contains("# Criterion bench: query path"));
300 }
301
302 #[test]
303 fn analysis_empty_body_gets_scaffold_sections() {
304 let dir = tempdir().unwrap();
305 let created = create_note(
306 dir.path(),
307 &NoteNewOptions {
308 node_type: NodeType::Analysis,
309 title: "egui vs tauri".into(),
310 note: None,
311 tags: vec![],
312 aliases: vec![],
313 dir: None,
314 force: false,
315 },
316 )
317 .unwrap();
318 let text = std::fs::read_to_string(&created.path).unwrap();
319 assert!(text.contains("## Question / scope"));
320 assert!(text.contains("## Findings"));
321 assert!(text.contains("## Recommendations"));
322 assert!(text.contains("cargo bench"));
323 }
324
325 #[test]
326 fn plan_scaffold_under_docs_plans() {
327 let dir = tempdir().unwrap();
328 let created = create_note(
329 dir.path(),
330 &NoteNewOptions {
331 node_type: NodeType::Plan,
332 title: "Q3 roadmap".into(),
333 note: None,
334 tags: vec![],
335 aliases: vec![],
336 dir: None,
337 force: false,
338 },
339 )
340 .unwrap();
341 assert!(created
342 .rel_path
343 .to_string_lossy()
344 .starts_with("docs/plans/"));
345 let text = std::fs::read_to_string(&created.path).unwrap();
346 assert!(text.contains("node_type: plan"));
347 assert!(text.contains("## Items"));
348 assert!(text.contains("- [ ]"));
349 }
350
351 #[test]
352 fn creates_adr_with_body() {
353 let dir = tempdir().unwrap();
354 let created = create_note(
355 dir.path(),
356 &NoteNewOptions {
357 node_type: NodeType::Adr,
358 title: "Use SQLite".into(),
359 note: Some("We need embedded storage.".into()),
360 tags: vec!["storage".into()],
361 aliases: vec![],
362 dir: None,
363 force: false,
364 },
365 )
366 .unwrap();
367 assert!(created.path.exists());
368 let text = std::fs::read_to_string(&created.path).unwrap();
369 assert!(text.contains("node_type: adr"));
370 assert!(text.contains("# Use SQLite"));
371 assert!(text.contains("We need embedded storage."));
372 assert_eq!(created.node_id, "docs/adr/use-sqlite");
373 }
374
375 #[test]
376 fn refuses_overwrite_without_force() {
377 let dir = tempdir().unwrap();
378 let opts = NoteNewOptions {
379 node_type: NodeType::Concept,
380 title: "Dup".into(),
381 note: None,
382 tags: vec![],
383 aliases: vec![],
384 dir: None,
385 force: false,
386 };
387 create_note(dir.path(), &opts).unwrap();
388 assert!(create_note(dir.path(), &opts).is_err());
389 }
390}