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 pub scope: Option<String>,
28}
29
30#[derive(Debug, Clone)]
32pub struct NoteCreated {
33 pub path: PathBuf,
35 pub rel_path: PathBuf,
37 pub node_id: String,
39}
40
41pub fn default_dir_for_type(ty: &NodeType) -> &'static str {
43 match ty {
44 NodeType::Goal => "docs/goals",
45 NodeType::Adr => "docs/adr",
46 NodeType::Alternative => "docs/adr",
47 NodeType::Concept => "docs/concepts",
48 NodeType::Analysis => "docs/analysis",
49 NodeType::Symbol => "docs/concepts",
50 NodeType::Reference => "docs/concepts",
51 NodeType::EdgeCase => "docs/edge_cases",
52 NodeType::Changelog => "docs/changelogs",
54 NodeType::Plan => "docs/plans",
55 }
56}
57
58pub fn slugify_title(title: &str) -> String {
60 let mut out = String::new();
61 let mut prev_dash = false;
62 for c in title.chars() {
63 if c.is_ascii_alphanumeric() {
64 out.push(c.to_ascii_lowercase());
65 prev_dash = false;
66 } else if !prev_dash && !out.is_empty() {
67 out.push('-');
68 prev_dash = true;
69 }
70 }
71 while out.ends_with('-') {
72 out.pop();
73 }
74 if out.is_empty() {
75 "untitled".into()
76 } else {
77 out
78 }
79}
80
81pub fn create_note(workspace: &Path, opts: &NoteNewOptions) -> Result<NoteCreated> {
85 if opts.title.trim().is_empty() {
86 return Err(BrainError::other("note title must not be empty"));
87 }
88
89 let scope_id = opts
90 .scope
91 .as_deref()
92 .map(str::trim)
93 .filter(|s| !s.is_empty() && *s != crate::scopes::MAIN_SCOPE);
94
95 let dir_rel = if let Some(d) = &opts.dir {
96 d.clone()
97 } else if let Some(sid) = scope_id {
98 let m = crate::scopes::load_manifest(workspace)?;
100 if let Some(sc) = m.find_scope(sid) {
101 let root = sc.roots.first().map(|r| r.as_str()).unwrap_or("");
102 PathBuf::from(root).join(default_dir_for_type(&opts.node_type))
103 } else {
104 PathBuf::from(format!(
106 "docs/subbrains/{}/{}",
107 crate::scopes::sanitize_scope_id(sid)?,
108 default_dir_for_type(&opts.node_type).trim_start_matches("docs/")
109 ))
110 }
111 } else {
112 PathBuf::from(default_dir_for_type(&opts.node_type))
113 };
114 let abs_dir = workspace.join(&dir_rel);
115 std::fs::create_dir_all(&abs_dir)?;
116
117 let stem = slugify_title(&opts.title);
118 let filename = format!("{stem}.md");
119 let abs_path = abs_dir.join(&filename);
120 let rel_path = dir_rel.join(&filename);
121
122 if abs_path.exists() && !opts.force {
123 return Err(BrainError::other(format!(
124 "note already exists: {} (use --force to overwrite)",
125 rel_path.display()
126 )));
127 }
128
129 let mut tags = opts.tags.clone();
130 if tags.is_empty() {
131 tags.push(opts.node_type.as_str().to_string());
132 }
133
134 let tags_yaml = format_yaml_list(&tags);
135 let aliases_yaml = if opts.aliases.is_empty() {
136 String::new()
137 } else {
138 format!("aliases: {}\n", format_yaml_list(&opts.aliases))
139 };
140
141 let body = opts.note.as_deref().unwrap_or("").trim();
142 let body_section = if body.is_empty() {
143 match opts.node_type {
144 NodeType::Adr => {
145 "\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"
146 .to_string()
147 }
148 NodeType::Goal => {
149 "\n## Goals\n\n- \n\n## Non-goals\n\n- \n".to_string()
150 }
151 NodeType::Analysis => ANALYSIS_NOTE_TEMPLATE.to_string(),
152 NodeType::Plan => PLAN_NOTE_TEMPLATE.to_string(),
153 NodeType::Changelog => CHANGELOG_NOTE_TEMPLATE.to_string(),
154 _ => "\n".to_string(),
155 }
156 } else {
157 format!("\n{body}\n")
158 };
159
160 let status_yaml = if matches!(opts.node_type, NodeType::Plan) {
161 "status: backlog\n"
162 } else {
163 ""
164 };
165 let scope_yaml = opts
166 .scope
167 .as_deref()
168 .map(str::trim)
169 .filter(|s| !s.is_empty())
170 .map(|s| format!("scope: {s}\n"))
171 .unwrap_or_default();
172
173 let content = format!(
174 "---\n\
175 tags: {tags_yaml}\n\
176 node_type: {}\n\
177 {status_yaml}\
178 {scope_yaml}\
179 {aliases_yaml}\
180 ---\n\
181 # {}\n\
182 {body_section}",
183 opts.node_type.as_str(),
184 opts.title.trim(),
185 );
186
187 std::fs::write(&abs_path, content)?;
188
189 let node_id = node_id_from_rel_path(&rel_path);
190 Ok(NoteCreated {
191 path: abs_path,
192 rel_path,
193 node_id,
194 })
195}
196
197const ANALYSIS_NOTE_TEMPLATE: &str = r#"
199## Question / scope
200
201<!-- What are we investigating? (crate comparison, perf, design options, data, …) -->
202
203## When
204
205<!-- ISO date/time is useful in the title or here, e.g. 2026-07-31 -->
206
207## Findings
208
209-
210
211## Artifacts (optional)
212
213<!-- Links or summaries of evidence: criterion `cargo bench` output, tables, logs, PRs, plots. Prefer paths/commands over pasting huge dumps. -->
214
215-
216
217## Recommendations (optional — not a decision)
218
219<!-- Promotion path: if/when you commit, write an ADR and link it with [[…]]. -->
220
221-
222
223## Open questions / edge cases
224
225-
226
227## Related
228
229<!-- [[goals/…]] [[concepts/…]] symbol:Type::method [[docs/edge_cases/…]] -->
230
231"#;
232
233const PLAN_NOTE_TEMPLATE: &str = r#"
239## Status
240
241<!-- overall: backlog | in_progress | qa | done | cancelled | blocked -->
242in_progress
243
244## Intent
245
246<!-- What outcome does this plan unlock? Link goals: [[docs/goals/…]] -->
247
248## Backlog
249
250- [ ]
251
252## In Progress
253
254- [/]
255
256## QA
257
258- [?]
259
260## Done
261
262- [x]
263
264## Cancelled
265
266- [~]
267
268## Blocked
269
270- [!]
271
272## Priority / order
273
2741.
275
276## Out of scope
277
278-
279
280## Related
281
282<!-- [[changelog]] [[roadmap]] [[docs/adr/…]] analysis notes -->
283
284"#;
285
286const CHANGELOG_NOTE_TEMPLATE: &str = r#"
288## Version
289
290<!-- e.g. 0.4.0 — also update root CHANGELOG.md (Keep a Changelog) when shipping -->
291
292## Summary
293
294-
295
296## Added
297
298-
299
300## Changed
301
302-
303
304## Fixed
305
306-
307
308## Related
309
310<!-- Prefer the root hub: [[changelog]] ADRs: [[docs/adr/…]] -->
311
312"#;
313
314fn format_yaml_list(items: &[String]) -> String {
315 if items.is_empty() {
316 return "[]".into();
317 }
318 let inner = items
319 .iter()
320 .map(|t| {
321 if t.chars().any(|c| c.is_whitespace() || c == ':' || c == '#') {
322 format!("\"{}\"", t.replace('"', "\\\""))
323 } else {
324 t.clone()
325 }
326 })
327 .collect::<Vec<_>>()
328 .join(", ");
329 format!("[{inner}]")
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use tempfile::tempdir;
336
337 #[test]
338 fn creates_analysis_under_docs_analysis() {
339 let dir = tempdir().unwrap();
340 let created = create_note(
341 dir.path(),
342 &NoteNewOptions {
343 node_type: NodeType::Analysis,
344 title: "Criterion bench: query path".into(),
345 note: Some(
346 "Compared baseline vs patched run_sql. p50 improved 12% on cold cache.".into(),
347 ),
348 tags: vec!["bench".into(), "criterion".into()],
349 aliases: vec![],
350 dir: None,
351 force: false,
352 scope: None,
353 },
354 )
355 .unwrap();
356 assert!(created
357 .rel_path
358 .to_string_lossy()
359 .starts_with("docs/analysis/"));
360 let text = std::fs::read_to_string(&created.path).unwrap();
361 assert!(text.contains("node_type: analysis"));
362 assert!(text.contains("p50 improved"));
363 assert!(text.contains("# Criterion bench: query path"));
364 }
365
366 #[test]
367 fn analysis_empty_body_gets_scaffold_sections() {
368 let dir = tempdir().unwrap();
369 let created = create_note(
370 dir.path(),
371 &NoteNewOptions {
372 node_type: NodeType::Analysis,
373 title: "egui vs tauri".into(),
374 note: None,
375 tags: vec![],
376 aliases: vec![],
377 dir: None,
378 force: false,
379 scope: None,
380 },
381 )
382 .unwrap();
383 let text = std::fs::read_to_string(&created.path).unwrap();
384 assert!(text.contains("## Question / scope"));
385 assert!(text.contains("## Findings"));
386 assert!(text.contains("## Recommendations"));
387 assert!(text.contains("cargo bench"));
388 }
389
390 #[test]
391 fn plan_scaffold_under_docs_plans() {
392 let dir = tempdir().unwrap();
393 let created = create_note(
394 dir.path(),
395 &NoteNewOptions {
396 node_type: NodeType::Plan,
397 title: "Q3 roadmap".into(),
398 note: None,
399 tags: vec![],
400 aliases: vec![],
401 dir: None,
402 force: false,
403 scope: None,
404 },
405 )
406 .unwrap();
407 assert!(created
408 .rel_path
409 .to_string_lossy()
410 .starts_with("docs/plans/"));
411 let text = std::fs::read_to_string(&created.path).unwrap();
412 assert!(text.contains("node_type: plan"));
413 assert!(text.contains("status: backlog"));
414 assert!(text.contains("## Backlog"));
415 assert!(text.contains("- [ ]"));
416 assert!(text.contains("## Done"));
417 }
418
419 #[test]
420 fn creates_adr_with_body() {
421 let dir = tempdir().unwrap();
422 let created = create_note(
423 dir.path(),
424 &NoteNewOptions {
425 node_type: NodeType::Adr,
426 title: "Use SQLite".into(),
427 note: Some("We need embedded storage.".into()),
428 tags: vec!["storage".into()],
429 aliases: vec![],
430 dir: None,
431 force: false,
432 scope: None,
433 },
434 )
435 .unwrap();
436 assert!(created.path.exists());
437 let text = std::fs::read_to_string(&created.path).unwrap();
438 assert!(text.contains("node_type: adr"));
439 assert!(text.contains("# Use SQLite"));
440 assert!(text.contains("We need embedded storage."));
441 assert_eq!(created.node_id, "docs/adr/use-sqlite");
442 }
443
444 #[test]
445 fn refuses_overwrite_without_force() {
446 let dir = tempdir().unwrap();
447 let opts = NoteNewOptions {
448 node_type: NodeType::Concept,
449 title: "Dup".into(),
450 note: None,
451 tags: vec![],
452 aliases: vec![],
453 dir: None,
454 force: false,
455 scope: None,
456 };
457 create_note(dir.path(), &opts).unwrap();
458 assert!(create_note(dir.path(), &opts).is_err());
459 }
460}