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 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
165const 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
201const PLAN_NOTE_TEMPLATE: &str = r#"
207## Status
208
209<!-- overall: backlog | in_progress | qa | done | cancelled | undone -->
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## Priority / order
237
2381.
239
240## Out of scope
241
242-
243
244## Related
245
246<!-- [[changelog]] [[roadmap]] [[docs/adr/…]] analysis notes -->
247
248"#;
249
250const CHANGELOG_NOTE_TEMPLATE: &str = r#"
252## Version
253
254<!-- e.g. 0.4.0 — also update root CHANGELOG.md (Keep a Changelog) when shipping -->
255
256## Summary
257
258-
259
260## Added
261
262-
263
264## Changed
265
266-
267
268## Fixed
269
270-
271
272## Related
273
274<!-- Prefer the root hub: [[changelog]] ADRs: [[docs/adr/…]] -->
275
276"#;
277
278fn format_yaml_list(items: &[String]) -> String {
279 if items.is_empty() {
280 return "[]".into();
281 }
282 let inner = items
283 .iter()
284 .map(|t| {
285 if t.chars().any(|c| c.is_whitespace() || c == ':' || c == '#') {
286 format!("\"{}\"", t.replace('"', "\\\""))
287 } else {
288 t.clone()
289 }
290 })
291 .collect::<Vec<_>>()
292 .join(", ");
293 format!("[{inner}]")
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use tempfile::tempdir;
300
301 #[test]
302 fn creates_analysis_under_docs_analysis() {
303 let dir = tempdir().unwrap();
304 let created = create_note(
305 dir.path(),
306 &NoteNewOptions {
307 node_type: NodeType::Analysis,
308 title: "Criterion bench: query path".into(),
309 note: Some(
310 "Compared baseline vs patched run_sql. p50 improved 12% on cold cache.".into(),
311 ),
312 tags: vec!["bench".into(), "criterion".into()],
313 aliases: vec![],
314 dir: None,
315 force: false,
316 },
317 )
318 .unwrap();
319 assert!(created
320 .rel_path
321 .to_string_lossy()
322 .starts_with("docs/analysis/"));
323 let text = std::fs::read_to_string(&created.path).unwrap();
324 assert!(text.contains("node_type: analysis"));
325 assert!(text.contains("p50 improved"));
326 assert!(text.contains("# Criterion bench: query path"));
327 }
328
329 #[test]
330 fn analysis_empty_body_gets_scaffold_sections() {
331 let dir = tempdir().unwrap();
332 let created = create_note(
333 dir.path(),
334 &NoteNewOptions {
335 node_type: NodeType::Analysis,
336 title: "egui vs tauri".into(),
337 note: None,
338 tags: vec![],
339 aliases: vec![],
340 dir: None,
341 force: false,
342 },
343 )
344 .unwrap();
345 let text = std::fs::read_to_string(&created.path).unwrap();
346 assert!(text.contains("## Question / scope"));
347 assert!(text.contains("## Findings"));
348 assert!(text.contains("## Recommendations"));
349 assert!(text.contains("cargo bench"));
350 }
351
352 #[test]
353 fn plan_scaffold_under_docs_plans() {
354 let dir = tempdir().unwrap();
355 let created = create_note(
356 dir.path(),
357 &NoteNewOptions {
358 node_type: NodeType::Plan,
359 title: "Q3 roadmap".into(),
360 note: None,
361 tags: vec![],
362 aliases: vec![],
363 dir: None,
364 force: false,
365 },
366 )
367 .unwrap();
368 assert!(created
369 .rel_path
370 .to_string_lossy()
371 .starts_with("docs/plans/"));
372 let text = std::fs::read_to_string(&created.path).unwrap();
373 assert!(text.contains("node_type: plan"));
374 assert!(text.contains("status: backlog"));
375 assert!(text.contains("## Backlog"));
376 assert!(text.contains("- [ ]"));
377 assert!(text.contains("## Done"));
378 }
379
380 #[test]
381 fn creates_adr_with_body() {
382 let dir = tempdir().unwrap();
383 let created = create_note(
384 dir.path(),
385 &NoteNewOptions {
386 node_type: NodeType::Adr,
387 title: "Use SQLite".into(),
388 note: Some("We need embedded storage.".into()),
389 tags: vec!["storage".into()],
390 aliases: vec![],
391 dir: None,
392 force: false,
393 },
394 )
395 .unwrap();
396 assert!(created.path.exists());
397 let text = std::fs::read_to_string(&created.path).unwrap();
398 assert!(text.contains("node_type: adr"));
399 assert!(text.contains("# Use SQLite"));
400 assert!(text.contains("We need embedded storage."));
401 assert_eq!(created.node_id, "docs/adr/use-sqlite");
402 }
403
404 #[test]
405 fn refuses_overwrite_without_force() {
406 let dir = tempdir().unwrap();
407 let opts = NoteNewOptions {
408 node_type: NodeType::Concept,
409 title: "Dup".into(),
410 note: None,
411 tags: vec![],
412 aliases: vec![],
413 dir: None,
414 force: false,
415 };
416 create_note(dir.path(), &opts).unwrap();
417 assert!(create_note(dir.path(), &opts).is_err());
418 }
419}