git_adr/cli/
import.rs

1//! Import ADRs from files.
2
3use 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/// Arguments for the import command.
12#[derive(ClapArgs, Debug)]
13pub struct Args {
14    /// Path to import from (file or directory).
15    pub path: String,
16
17    /// Import format (auto, markdown, json, adr-tools).
18    #[arg(long, short, default_value = "auto")]
19    pub format: String,
20
21    /// Link ADRs to commits by date.
22    #[arg(long)]
23    pub link_by_date: bool,
24
25    /// Preview import without saving.
26    #[arg(long)]
27    pub dry_run: bool,
28}
29
30/// Run the import command.
31///
32/// # Errors
33///
34/// Returns an error if import fails.
35pub 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 markdown files in directory
50        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, &notes, &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
116/// Find ADR files in a directory.
117fn 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    // Sort by filename for consistent ordering
133    files.sort();
134
135    Ok(files)
136}
137
138/// Import a single file.
139fn 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
161/// Detect file format.
162fn 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    // Check for adr-tools format (numbered prefix like "0001-")
174    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    // Check for YAML frontmatter
180    if content.trim_start().starts_with("---") {
181        return "markdown".to_string();
182    }
183
184    "markdown".to_string()
185}
186
187/// Import from JSON format.
188fn 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    // Import tags if present
215    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
225/// Import from adr-tools format (numbered markdown files).
226fn 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    // Parse number and title from filename (e.g., "0001-use-postgresql")
238    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    // Extract title from filename or first heading
244    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    // Parse status from content
251    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
262/// Import from markdown with YAML frontmatter.
263fn import_markdown(
264    content: &str,
265    config: &crate::core::AdrConfig,
266    notes: &NotesManager,
267) -> Result<Adr> {
268    // Try to parse as full ADR with frontmatter
269    if let Ok(adr) = Adr::from_markdown("temp".to_string(), String::new(), content) {
270        // Generate new ID if needed
271        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    // Fall back to simple markdown parsing
282    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
295/// Extract title from markdown content.
296fn 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
306/// Extract status from markdown content.
307fn extract_status_from_content(content: &str) -> Option<String> {
308    let content_lower = content.to_lowercase();
309
310    // Look for "## Status" section
311    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            // Common statuses
319            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}