1use anyhow::Result;
4use clap::Args as ClapArgs;
5use colored::Colorize;
6use std::fs;
7use std::path::Path;
8
9use crate::core::{Adr, AdrStatus, ConfigManager, Git, NotesManager};
10
11#[derive(ClapArgs, Debug)]
13pub struct Args {
14 pub path: String,
16
17 #[arg(long, short, default_value = "auto")]
19 pub format: String,
20
21 #[arg(long)]
23 pub link_by_date: bool,
24
25 #[arg(long)]
27 pub dry_run: bool,
28}
29
30pub fn run(args: Args) -> Result<()> {
36 let git = Git::new();
37 git.check_repository()?;
38
39 let config = ConfigManager::new(git.clone()).load()?;
40 let notes = NotesManager::new(git, config.clone());
41
42 let path = Path::new(&args.path);
43
44 if !path.exists() {
45 anyhow::bail!("Path not found: {}", args.path);
46 }
47
48 let files = if path.is_dir() {
49 find_adr_files(path)?
51 } else {
52 vec![path.to_path_buf()]
53 };
54
55 if files.is_empty() {
56 eprintln!("{} No ADR files found to import", "!".yellow());
57 return Ok(());
58 }
59
60 eprintln!(
61 "{} Found {} file(s) to import",
62 "→".blue(),
63 files.len().to_string().cyan()
64 );
65
66 let mut imported = 0;
67 let mut skipped = 0;
68
69 for file in &files {
70 match import_file(file, &args, ¬es, &config) {
71 Ok(adr) => {
72 if args.dry_run {
73 eprintln!(
74 " {} Would import: {} - {}",
75 "→".blue(),
76 adr.id.cyan(),
77 adr.frontmatter.title
78 );
79 } else {
80 eprintln!(
81 " {} Imported: {} - {}",
82 "✓".green(),
83 adr.id.cyan(),
84 adr.frontmatter.title
85 );
86 }
87 imported += 1;
88 },
89 Err(e) => {
90 eprintln!(" {} Skipped {}: {}", "!".yellow(), file.display(), e);
91 skipped += 1;
92 },
93 }
94 }
95
96 eprintln!();
97 if args.dry_run {
98 eprintln!(
99 "{} Dry run complete: {} would be imported, {} would be skipped",
100 "→".blue(),
101 imported.to_string().green(),
102 skipped.to_string().yellow()
103 );
104 } else {
105 eprintln!(
106 "{} Import complete: {} imported, {} skipped",
107 "✓".green(),
108 imported.to_string().green(),
109 skipped.to_string().yellow()
110 );
111 }
112
113 Ok(())
114}
115
116fn find_adr_files(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
118 let mut files = Vec::new();
119
120 for entry in fs::read_dir(dir)? {
121 let entry = entry?;
122 let path = entry.path();
123
124 if path.is_file() {
125 let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
126 if ext == "md" || ext == "markdown" || ext == "json" {
127 files.push(path);
128 }
129 }
130 }
131
132 files.sort();
134
135 Ok(files)
136}
137
138fn import_file(
140 path: &Path,
141 args: &Args,
142 notes: &NotesManager,
143 config: &crate::core::AdrConfig,
144) -> Result<Adr> {
145 let content = fs::read_to_string(path)?;
146 let format = detect_format(path, &args.format, &content);
147
148 let adr = match format.as_str() {
149 "json" => import_json(&content)?,
150 "adr-tools" => import_adr_tools(path, &content, config, notes)?,
151 _ => import_markdown(&content, config, notes)?,
152 };
153
154 if !args.dry_run {
155 notes.create(&adr)?;
156 }
157
158 Ok(adr)
159}
160
161fn detect_format(path: &Path, hint: &str, content: &str) -> String {
163 if hint != "auto" {
164 return hint.to_string();
165 }
166
167 let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
168
169 if ext == "json" {
170 return "json".to_string();
171 }
172
173 let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
175 if filename.len() >= 5 && filename[..4].chars().all(|c| c.is_ascii_digit()) {
176 return "adr-tools".to_string();
177 }
178
179 if content.trim_start().starts_with("---") {
181 return "markdown".to_string();
182 }
183
184 "markdown".to_string()
185}
186
187fn import_json(content: &str) -> Result<Adr> {
189 let data: serde_json::Value = serde_json::from_str(content)?;
190
191 let id = data["id"]
192 .as_str()
193 .ok_or_else(|| anyhow::anyhow!("Missing 'id' field"))?
194 .to_string();
195
196 let title = data["title"]
197 .as_str()
198 .ok_or_else(|| anyhow::anyhow!("Missing 'title' field"))?
199 .to_string();
200
201 let status_str = data["status"].as_str().unwrap_or("proposed");
202 let status: AdrStatus = status_str.parse().unwrap_or_default();
203
204 let body = data["body"]
205 .as_str()
206 .or_else(|| data["content"].as_str())
207 .unwrap_or("")
208 .to_string();
209
210 let mut adr = Adr::new(id, title);
211 adr.frontmatter.status = status;
212 adr.body = body;
213
214 if let Some(tags) = data["tags"].as_array() {
216 adr.frontmatter.tags = tags
217 .iter()
218 .filter_map(|v| v.as_str().map(String::from))
219 .collect();
220 }
221
222 Ok(adr)
223}
224
225fn import_adr_tools(
227 path: &Path,
228 content: &str,
229 config: &crate::core::AdrConfig,
230 notes: &NotesManager,
231) -> Result<Adr> {
232 let filename = path
233 .file_stem()
234 .and_then(|s| s.to_str())
235 .unwrap_or("unknown");
236
237 let parts: Vec<&str> = filename.splitn(2, '-').collect();
239 let number: u32 = parts.first().unwrap_or(&"1").parse().unwrap_or(1);
240
241 let id = notes.format_id(number);
242
243 let title = if parts.len() > 1 {
245 parts[1].replace('-', " ")
246 } else {
247 extract_title_from_content(content).unwrap_or_else(|| "Untitled".to_string())
248 };
249
250 let status_str = extract_status_from_content(content).unwrap_or_else(|| "proposed".to_string());
252 let status: AdrStatus = status_str.parse().unwrap_or_default();
253
254 let mut adr = Adr::new(id, title);
255 adr.frontmatter.status = status;
256 adr.body = content.to_string();
257 adr.frontmatter.format = Some(config.format.clone());
258
259 Ok(adr)
260}
261
262fn import_markdown(
264 content: &str,
265 config: &crate::core::AdrConfig,
266 notes: &NotesManager,
267) -> Result<Adr> {
268 if let Ok(adr) = Adr::from_markdown("temp".to_string(), String::new(), content) {
270 let id = if adr.id == "temp" || adr.id.is_empty() {
272 let next_num = notes.next_number()?;
273 notes.format_id(next_num)
274 } else {
275 adr.id
276 };
277
278 return Ok(Adr { id, ..adr });
279 }
280
281 let title = extract_title_from_content(content)
283 .ok_or_else(|| anyhow::anyhow!("Could not determine ADR title"))?;
284
285 let next_num = notes.next_number()?;
286 let id = notes.format_id(next_num);
287
288 let mut adr = Adr::new(id, title);
289 adr.body = content.to_string();
290 adr.frontmatter.format = Some(config.format.clone());
291
292 Ok(adr)
293}
294
295fn extract_title_from_content(content: &str) -> Option<String> {
297 for line in content.lines() {
298 let trimmed = line.trim();
299 if let Some(title) = trimmed.strip_prefix("# ") {
300 return Some(title.trim().to_string());
301 }
302 }
303 None
304}
305
306fn extract_status_from_content(content: &str) -> Option<String> {
308 let content_lower = content.to_lowercase();
309
310 if let Some(idx) = content_lower.find("## status") {
312 let after = &content[idx..];
313 for line in after.lines().skip(1) {
314 let trimmed = line.trim().to_lowercase();
315 if trimmed.is_empty() {
316 continue;
317 }
318 for status in &[
320 "accepted",
321 "proposed",
322 "deprecated",
323 "superseded",
324 "rejected",
325 "draft",
326 ] {
327 if trimmed.contains(status) {
328 return Some((*status).to_string());
329 }
330 }
331 break;
332 }
333 }
334
335 None
336}