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 | 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
254const 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}