Skip to main content

mps/commands/
import_cmd.rs

1use crate::config::Config;
2use crate::constants::mps_file_name_regexp;
3use anyhow::{Context, Result};
4use chrono::NaiveDate;
5use colored::Colorize;
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10/// A legacy file discovered during scanning.
11pub struct LegacyFile {
12    pub path: PathBuf,
13    pub stem: String,
14    pub ext: String,
15    pub date: NaiveDate,
16    pub content: String,
17}
18
19/// Discover legacy files: `.ms` files in `mps_dir` and non-standard `.mps`
20/// files in `storage_dir`. Skips files ending with `.imported`.
21pub fn collect_legacy_files(mps_dir: &Path, storage_dir: &Path) -> Result<Vec<LegacyFile>> {
22    let re = mps_file_name_regexp();
23    let mut files: Vec<LegacyFile> = Vec::new();
24
25    // Scan mps_dir for .ms files
26    if let Ok(rd) = std::fs::read_dir(mps_dir) {
27        for entry in rd.filter_map(|e| e.ok()) {
28            let path = entry.path();
29            if !path.is_file() {
30                continue;
31            }
32            let fname = match path.file_name().and_then(|n| n.to_str()) {
33                Some(n) => n.to_string(),
34                None => continue,
35            };
36            // Skip .imported markers
37            if fname.ends_with(".imported") {
38                continue;
39            }
40            if path.extension().and_then(|e| e.to_str()) == Some("ms") {
41                if let Some(lf) = read_legacy(&path, &fname) {
42                    files.push(lf);
43                }
44            }
45        }
46    }
47
48    // Scan storage_dir for non-standard .mps files
49    if let Ok(rd) = std::fs::read_dir(storage_dir) {
50        for entry in rd.filter_map(|e| e.ok()) {
51            let path = entry.path();
52            if !path.is_file() {
53                continue;
54            }
55            let fname = match path.file_name().and_then(|n| n.to_str()) {
56                Some(n) => n.to_string(),
57                None => continue,
58            };
59            if fname.ends_with(".imported") {
60                continue;
61            }
62            if path.extension().and_then(|e| e.to_str()) == Some("mps") && !re.is_match(&fname) {
63                if let Some(lf) = read_legacy(&path, &fname) {
64                    files.push(lf);
65                }
66            }
67        }
68    }
69
70    files.sort_by(|a, b| a.date.cmp(&b.date).then(a.stem.cmp(&b.stem)));
71    Ok(files)
72}
73
74fn read_legacy(path: &Path, fname: &str) -> Option<LegacyFile> {
75    // Determine stem and extension robustly: stem = everything before the LAST dot
76    let (stem, ext) = match fname.rsplit_once('.') {
77        Some((s, e)) => (s.to_string(), e.to_string()),
78        None => (fname.to_string(), String::new()),
79    };
80
81    let date = mtime_date(path).unwrap_or_else(|_| chrono::Local::now().date_naive());
82    let content = std::fs::read_to_string(path).unwrap_or_default();
83
84    Some(LegacyFile {
85        path: path.to_path_buf(),
86        stem,
87        ext,
88        date,
89        content,
90    })
91}
92
93fn mtime_date(path: &Path) -> Result<NaiveDate> {
94    let meta = std::fs::metadata(path).with_context(|| format!("stat {}", path.display()))?;
95    let mtime = meta.modified().context("mtime not available")?;
96    let secs = mtime
97        .duration_since(UNIX_EPOCH)
98        .unwrap_or_default()
99        .as_secs();
100    let dt = chrono::DateTime::from_timestamp(secs as i64, 0).context("invalid timestamp")?;
101    Ok(dt.date_naive())
102}
103
104/// Normalise a filename stem to a tag: lowercase, underscores → hyphens.
105pub fn normalise_tag(stem: &str) -> String {
106    stem.to_lowercase().replace('_', "-")
107}
108
109/// Format a legacy file's content as a `@note` element.
110pub fn format_as_note(stem: &str, content: &str) -> String {
111    let indented: String = content
112        .lines()
113        .map(|l| {
114            if l.trim().is_empty() {
115                String::new()
116            } else {
117                format!("  {l}")
118            }
119        })
120        .collect::<Vec<_>>()
121        .join("\n");
122    format!(
123        "@note[{}]{{\n  {stem}\n\n{indented}\n}}\n",
124        normalise_tag(stem)
125    )
126}
127
128pub fn run(config: &Config, dry_run: bool, yes: bool, move_: bool) -> Result<()> {
129    let legacy = collect_legacy_files(&config.mps_dir, &config.storage_dir)?;
130
131    if legacy.is_empty() {
132        println!("{}", "mps import — no legacy files found.".white());
133        println!("  Looked for:");
134        println!("    .ms  files in  {}", config.mps_dir.display());
135        println!(
136            "    non-standard .mps files in  {}",
137            config.storage_dir.display()
138        );
139        return Ok(());
140    }
141
142    // Group by date
143    let mut groups: BTreeMap<NaiveDate, Vec<&LegacyFile>> = BTreeMap::new();
144    for lf in &legacy {
145        groups.entry(lf.date).or_default().push(lf);
146    }
147
148    // Pre-compute output paths (one per date group, same epoch for whole run)
149    let epoch = std::time::SystemTime::now()
150        .duration_since(UNIX_EPOCH)
151        .unwrap_or_default()
152        .as_secs();
153
154    let output_paths: BTreeMap<NaiveDate, PathBuf> = groups
155        .keys()
156        .enumerate()
157        .map(|(i, &date)| {
158            let fname = format!("{}.{}.mps", date.format("%Y%m%d"), epoch + i as u64);
159            (date, config.storage_dir.join(fname))
160        })
161        .collect();
162
163    // Print preview table
164    let mode_label = if dry_run || (!yes && !move_) {
165        "dry run (pass --yes to execute)"
166    } else if move_ {
167        "execute — originals will be deleted"
168    } else {
169        "execute — originals will be renamed to .imported"
170    };
171    println!(
172        "\n{}  {}\n",
173        "mps import —".white().bold(),
174        mode_label.white()
175    );
176
177    let col_w = legacy
178        .iter()
179        .map(|f| {
180            f.path
181                .file_name()
182                .and_then(|n| n.to_str())
183                .unwrap_or("")
184                .len()
185        })
186        .max()
187        .unwrap_or(20)
188        .max(20);
189
190    println!(
191        "  {:<col_w$}  {:<10}  \u{2192} output file",
192        "source file", "date"
193    );
194    println!("  {}", "─".repeat(col_w + 28));
195
196    let mut prev_date: Option<NaiveDate> = None;
197    for lf in &legacy {
198        let fname = lf.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
199        let out = output_paths[&lf.date]
200            .file_name()
201            .and_then(|n| n.to_str())
202            .unwrap_or("");
203        let same_note = if prev_date == Some(lf.date) {
204            " (same)"
205        } else {
206            ""
207        };
208        println!(
209            "  {:<col_w$}  {:<10}  → {}{}",
210            fname,
211            lf.date.format("%Y-%m-%d"),
212            out,
213            same_note
214        );
215        prev_date = Some(lf.date);
216    }
217
218    let file_count = legacy.len();
219    let group_count = groups.len();
220    println!(
221        "\n  {} {} → {} output {}",
222        file_count,
223        if file_count == 1 { "file" } else { "files" },
224        group_count,
225        if group_count == 1 { "file" } else { "files" }
226    );
227
228    if dry_run || (!yes && !move_) {
229        println!("  {}", "run with --yes to execute".cyan());
230        println!();
231        return Ok(());
232    }
233
234    println!();
235
236    // Execute
237    for (date, files) in &groups {
238        let out_path = &output_paths[date];
239        let content: String = files
240            .iter()
241            .map(|lf| format_as_note(&lf.stem, &lf.content))
242            .collect::<Vec<_>>()
243            .join("\n");
244
245        let tmp = out_path.with_extension("tmp");
246        std::fs::write(&tmp, &content).with_context(|| format!("write {}", tmp.display()))?;
247        std::fs::rename(&tmp, out_path)
248            .with_context(|| format!("rename to {}", out_path.display()))?;
249
250        println!(
251            "  {}  written {}  ({} element{})",
252            "✓".green().bold(),
253            out_path.file_name().and_then(|n| n.to_str()).unwrap_or(""),
254            files.len(),
255            if files.len() == 1 { "" } else { "s" }
256        );
257    }
258
259    // Post-import: rename or delete sources
260    for lf in &legacy {
261        if move_ {
262            std::fs::remove_file(&lf.path)
263                .with_context(|| format!("delete {}", lf.path.display()))?;
264        } else {
265            // rename to STEM.EXT.imported
266            let new_name = format!("{}.{}.imported", lf.stem, lf.ext);
267            let new_path = lf.path.parent().unwrap_or(Path::new(".")).join(&new_name);
268            std::fs::rename(&lf.path, &new_path)
269                .with_context(|| format!("rename {}", lf.path.display()))?;
270        }
271    }
272
273    println!();
274    println!("  {}  {} imported", "✓".green().bold(), file_count);
275    if !move_ {
276        println!("  Originals renamed to *.imported — they will be skipped on re-run.");
277    }
278    println!();
279
280    Ok(())
281}