Skip to main content

algocline_app/service/
migrate.rs

1//! `alc_migrate` — convert legacy `alc.lock` to `alc.toml` + new `alc.lock`.
2
3use super::alc_toml::{alc_toml_path, save_alc_toml};
4use super::lockfile::lockfile_path;
5use super::AppService;
6
7impl AppService {
8    pub async fn migrate(&self, project_root: Option<String>) -> Result<String, String> {
9        // Resolve root: explicit → ALC_PROJECT_ROOT → cwd.
10        // walk_up is not used because alc.toml may not exist yet.
11        let root = if let Some(s) = project_root.as_deref() {
12            std::path::PathBuf::from(s)
13        } else if let Ok(env) = std::env::var("ALC_PROJECT_ROOT") {
14            if !env.is_empty() {
15                std::path::PathBuf::from(env)
16            } else {
17                std::env::current_dir().map_err(|e| format!("Cannot determine cwd: {e}"))?
18            }
19        } else {
20            std::env::current_dir().map_err(|e| format!("Cannot determine cwd: {e}"))?
21        };
22
23        let lock_path = lockfile_path(&root);
24        if !lock_path.exists() {
25            return Ok(serde_json::json!({
26                "status": "nothing to migrate",
27                "reason": "alc.lock not found"
28            })
29            .to_string());
30        }
31
32        let content = std::fs::read_to_string(&lock_path)
33            .map_err(|e| format!("Failed to read alc.lock: {e}"))?;
34
35        // Detect legacy format by parsing TOML and checking for structural
36        // markers: `linked_at` fields on [[package]] entries or source
37        // `type = "local_dir"`.  String-contains was previously used but
38        // could false-positive on package names containing those strings.
39        let is_legacy = detect_legacy_format(&content);
40        if !is_legacy {
41            return Ok(serde_json::json!({
42                "status": "nothing to migrate",
43                "reason": "already new format or no local_dir entries"
44            })
45            .to_string());
46        }
47
48        let toml_path = alc_toml_path(&root);
49        if toml_path.exists() {
50            return Err(format!(
51                "alc.toml already exists at {}. Remove it first or migrate manually.",
52                toml_path.display()
53            ));
54        }
55
56        // Parse legacy TOML line-by-line to extract local_dir entries.
57        let mut doc: toml_edit::DocumentMut = "[packages]\n"
58            .parse()
59            .map_err(|e: toml_edit::TomlError| format!("Internal error: {e}"))?;
60
61        {
62            let mut current_name: Option<String> = None;
63            let mut current_path: Option<String> = None;
64            let mut in_local_dir = false;
65
66            let flush =
67                |doc: &mut toml_edit::DocumentMut, name: Option<String>, path: Option<String>| {
68                    if let (Some(n), Some(p)) = (name, path) {
69                        if let Some(tbl) = doc["packages"].as_table_mut() {
70                            let mut inline = toml_edit::InlineTable::new();
71                            inline.insert("path", p.as_str().into());
72                            tbl.insert(
73                                &n,
74                                toml_edit::Item::Value(toml_edit::Value::InlineTable(inline)),
75                            );
76                        }
77                    }
78                };
79
80            for line in content.lines() {
81                let trimmed = line.trim();
82                if trimmed == "[[package]]" {
83                    if in_local_dir {
84                        flush(&mut doc, current_name.take(), current_path.take());
85                    }
86                    current_name = None;
87                    current_path = None;
88                    in_local_dir = false;
89                } else if let Some(v) = trimmed.strip_prefix("name = ") {
90                    current_name = Some(v.trim_matches('"').to_string());
91                } else if trimmed.contains("local_dir") {
92                    in_local_dir = true;
93                } else if in_local_dir {
94                    if let Some(v) = trimmed.strip_prefix("path = ") {
95                        current_path = Some(v.trim_matches('"').to_string());
96                    }
97                }
98            }
99            if in_local_dir {
100                flush(&mut doc, current_name, current_path);
101            }
102        }
103
104        // Atomic: write alc.toml first, then rename alc.lock to backup.
105        save_alc_toml(&root, &doc)?;
106
107        let bak_path = lock_path.with_extension("lock.bak");
108        std::fs::rename(&lock_path, &bak_path)
109            .map_err(|e| format!("Failed to rename alc.lock to alc.lock.bak: {e}"))?;
110
111        let result = serde_json::json!({
112            "migrated": true,
113            "alc_toml": toml_path.display().to_string(),
114            "backup": bak_path.display().to_string(),
115            "note": "Run alc_update to generate new alc.lock"
116        });
117        Ok(result.to_string())
118    }
119}
120
121/// Detect whether `alc.lock` content uses the legacy format.
122///
123/// Legacy indicators (checked via TOML parse, not string search):
124/// - Any `[[package]]` entry has a `linked_at` key
125/// - Any `[package.source]` has `type = "local_dir"`
126fn detect_legacy_format(content: &str) -> bool {
127    let parsed: toml::Value = match toml::from_str(content) {
128        Ok(v) => v,
129        Err(_) => return false,
130    };
131
132    let packages = match parsed.get("package").and_then(|v| v.as_array()) {
133        Some(arr) => arr,
134        None => return false,
135    };
136
137    for pkg in packages {
138        let tbl = match pkg.as_table() {
139            Some(t) => t,
140            None => continue,
141        };
142
143        if tbl.contains_key("linked_at") {
144            return true;
145        }
146
147        if let Some(source) = tbl.get("source").and_then(|s| s.as_table()) {
148            if source.get("type").and_then(|t| t.as_str()) == Some("local_dir") {
149                return true;
150            }
151        }
152    }
153
154    false
155}
156
157#[cfg(test)]
158mod tests {
159    use crate::service::test_support::make_app_service as make_service;
160
161    const LEGACY_LOCK: &str = r#"version = 1
162
163[[package]]
164name = "my_pkg"
165linked_at = "2024-01-01T00:00:00Z"
166
167[package.source]
168type = "local_dir"
169path = "/some/path/my_pkg"
170"#;
171
172    #[tokio::test]
173    async fn migrate_when_no_lock_returns_nothing_to_migrate() {
174        let tmp = tempfile::tempdir().unwrap();
175        let svc = make_service().await;
176        let result = svc
177            .migrate(Some(tmp.path().to_str().unwrap().to_string()))
178            .await
179            .unwrap();
180        assert!(result.contains("nothing to migrate"), "{result}");
181        assert!(result.contains("alc.lock not found"), "{result}");
182    }
183
184    #[tokio::test]
185    async fn migrate_new_format_lock_returns_nothing_to_migrate() {
186        let tmp = tempfile::tempdir().unwrap();
187        std::fs::write(
188            tmp.path().join("alc.lock"),
189            "version = 1\n\n[[package]]\nname = \"cot\"\n\n[package.source]\ntype = \"installed\"\n",
190        )
191        .unwrap();
192        let svc = make_service().await;
193        let result = svc
194            .migrate(Some(tmp.path().to_str().unwrap().to_string()))
195            .await
196            .unwrap();
197        assert!(result.contains("nothing to migrate"), "{result}");
198    }
199
200    #[tokio::test]
201    async fn migrate_legacy_lock_creates_alc_toml_and_backup() {
202        let tmp = tempfile::tempdir().unwrap();
203        std::fs::write(tmp.path().join("alc.lock"), LEGACY_LOCK).unwrap();
204        let svc = make_service().await;
205        let result = svc
206            .migrate(Some(tmp.path().to_str().unwrap().to_string()))
207            .await
208            .unwrap();
209        assert!(result.contains("\"migrated\":true"), "{result}");
210        assert!(tmp.path().join("alc.toml").exists());
211        assert!(tmp.path().join("alc.lock.bak").exists());
212        assert!(!tmp.path().join("alc.lock").exists());
213
214        let toml_content = std::fs::read_to_string(tmp.path().join("alc.toml")).unwrap();
215        assert!(toml_content.contains("[packages]"), "{toml_content}");
216        assert!(toml_content.contains("my_pkg"), "{toml_content}");
217    }
218
219    #[tokio::test]
220    async fn migrate_fails_if_alc_toml_already_exists() {
221        let tmp = tempfile::tempdir().unwrap();
222        std::fs::write(tmp.path().join("alc.lock"), LEGACY_LOCK).unwrap();
223        std::fs::write(tmp.path().join("alc.toml"), "[packages]\n").unwrap();
224        let svc = make_service().await;
225        let err = svc
226            .migrate(Some(tmp.path().to_str().unwrap().to_string()))
227            .await
228            .unwrap_err();
229        assert!(err.contains("already exists"), "{err}");
230    }
231}