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::Symbol => "docs/concepts",
46 NodeType::Reference => "docs/concepts",
47 NodeType::EdgeCase => "docs/edge_cases",
48 }
49}
50
51pub fn slugify_title(title: &str) -> String {
53 let mut out = String::new();
54 let mut prev_dash = false;
55 for c in title.chars() {
56 if c.is_ascii_alphanumeric() {
57 out.push(c.to_ascii_lowercase());
58 prev_dash = false;
59 } else if !prev_dash && !out.is_empty() {
60 out.push('-');
61 prev_dash = true;
62 }
63 }
64 while out.ends_with('-') {
65 out.pop();
66 }
67 if out.is_empty() {
68 "untitled".into()
69 } else {
70 out
71 }
72}
73
74pub fn create_note(workspace: &Path, opts: &NoteNewOptions) -> Result<NoteCreated> {
78 if opts.title.trim().is_empty() {
79 return Err(BrainError::other("note title must not be empty"));
80 }
81
82 let dir_rel = opts
83 .dir
84 .clone()
85 .unwrap_or_else(|| PathBuf::from(default_dir_for_type(&opts.node_type)));
86 let abs_dir = workspace.join(&dir_rel);
87 std::fs::create_dir_all(&abs_dir)?;
88
89 let stem = slugify_title(&opts.title);
90 let filename = format!("{stem}.md");
91 let abs_path = abs_dir.join(&filename);
92 let rel_path = dir_rel.join(&filename);
93
94 if abs_path.exists() && !opts.force {
95 return Err(BrainError::other(format!(
96 "note already exists: {} (use --force to overwrite)",
97 rel_path.display()
98 )));
99 }
100
101 let mut tags = opts.tags.clone();
102 if tags.is_empty() {
103 tags.push(opts.node_type.as_str().to_string());
104 }
105
106 let tags_yaml = format_yaml_list(&tags);
107 let aliases_yaml = if opts.aliases.is_empty() {
108 String::new()
109 } else {
110 format!("aliases: {}\n", format_yaml_list(&opts.aliases))
111 };
112
113 let body = opts.note.as_deref().unwrap_or("").trim();
114 let body_section = if body.is_empty() {
115 match opts.node_type {
116 NodeType::Adr => {
117 "\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"
118 .to_string()
119 }
120 NodeType::Goal => {
121 "\n## Goals\n\n- \n\n## Non-goals\n\n- \n".to_string()
122 }
123 _ => "\n".to_string(),
124 }
125 } else {
126 format!("\n{body}\n")
127 };
128
129 let content = format!(
130 "---\n\
131 tags: {tags_yaml}\n\
132 node_type: {}\n\
133 {aliases_yaml}\
134 ---\n\
135 # {}\n\
136 {body_section}",
137 opts.node_type.as_str(),
138 opts.title.trim(),
139 );
140
141 std::fs::write(&abs_path, content)?;
142
143 let node_id = node_id_from_rel_path(&rel_path);
144 Ok(NoteCreated {
145 path: abs_path,
146 rel_path,
147 node_id,
148 })
149}
150
151fn format_yaml_list(items: &[String]) -> String {
152 if items.is_empty() {
153 return "[]".into();
154 }
155 let inner = items
156 .iter()
157 .map(|t| {
158 if t.chars().any(|c| c.is_whitespace() || c == ':' || c == '#') {
159 format!("\"{}\"", t.replace('"', "\\\""))
160 } else {
161 t.clone()
162 }
163 })
164 .collect::<Vec<_>>()
165 .join(", ");
166 format!("[{inner}]")
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use tempfile::tempdir;
173
174 #[test]
175 fn creates_adr_with_body() {
176 let dir = tempdir().unwrap();
177 let created = create_note(
178 dir.path(),
179 &NoteNewOptions {
180 node_type: NodeType::Adr,
181 title: "Use SQLite".into(),
182 note: Some("We need embedded storage.".into()),
183 tags: vec!["storage".into()],
184 aliases: vec![],
185 dir: None,
186 force: false,
187 },
188 )
189 .unwrap();
190 assert!(created.path.exists());
191 let text = std::fs::read_to_string(&created.path).unwrap();
192 assert!(text.contains("node_type: adr"));
193 assert!(text.contains("# Use SQLite"));
194 assert!(text.contains("We need embedded storage."));
195 assert_eq!(created.node_id, "docs/adr/use-sqlite");
196 }
197
198 #[test]
199 fn refuses_overwrite_without_force() {
200 let dir = tempdir().unwrap();
201 let opts = NoteNewOptions {
202 node_type: NodeType::Concept,
203 title: "Dup".into(),
204 note: None,
205 tags: vec![],
206 aliases: vec![],
207 dir: None,
208 force: false,
209 };
210 create_note(dir.path(), &opts).unwrap();
211 assert!(create_note(dir.path(), &opts).is_err());
212 }
213}