Skip to main content

pi/
migrations.rs

1//! Startup migrations for legacy Pi layouts and config formats.
2
3use std::collections::BTreeSet;
4use std::fs::{self, File};
5use std::io::{BufRead, BufReader};
6use std::path::{Path, PathBuf};
7
8use serde_json::{Map, Value};
9
10use crate::config::Config;
11use crate::session::encode_cwd;
12
13const MIGRATION_GUIDE_URL: &str = "https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration";
14const EXTENSIONS_DOC_URL: &str =
15    "https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md";
16
17const MANAGED_TOOL_BINARIES: &[&str] = &["fd", "rg", "fd.exe", "rg.exe"];
18
19/// Summary of startup migration actions.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct MigrationReport {
22    /// Providers migrated into `auth.json`.
23    pub migrated_auth_providers: Vec<String>,
24    /// Number of session files moved from `~/.pi/agent/*.jsonl` to `sessions/<encoded-cwd>/`.
25    pub migrated_session_files: usize,
26    /// Directories where `commands/` was renamed to `prompts/`.
27    pub migrated_commands_dirs: Vec<PathBuf>,
28    /// Managed binaries moved from `tools/` to `bin/`.
29    pub migrated_tool_binaries: Vec<String>,
30    /// Deprecated layout warnings (hooks/tools).
31    pub deprecation_warnings: Vec<String>,
32    /// Non-fatal migration execution warnings.
33    pub warnings: Vec<String>,
34}
35
36impl MigrationReport {
37    #[must_use]
38    pub fn messages(&self) -> Vec<String> {
39        let mut messages = Vec::new();
40
41        if !self.migrated_auth_providers.is_empty() {
42            messages.push(format!(
43                "Migrated legacy credentials into auth.json for providers: {}",
44                self.migrated_auth_providers.join(", ")
45            ));
46        }
47        if self.migrated_session_files > 0 {
48            messages.push(format!(
49                "Migrated {} legacy session file(s) into sessions/<encoded-cwd>/",
50                self.migrated_session_files
51            ));
52        }
53        if !self.migrated_commands_dirs.is_empty() {
54            let dirs = self
55                .migrated_commands_dirs
56                .iter()
57                .map(|path| path.display().to_string())
58                .collect::<Vec<_>>()
59                .join(", ");
60            messages.push(format!("Migrated commands/ -> prompts/ at: {dirs}"));
61        }
62        if !self.migrated_tool_binaries.is_empty() {
63            messages.push(format!(
64                "Migrated managed binaries tools/ -> bin/: {}",
65                self.migrated_tool_binaries.join(", ")
66            ));
67        }
68
69        for warning in &self.warnings {
70            messages.push(format!("Warning: {warning}"));
71        }
72        for warning in &self.deprecation_warnings {
73            messages.push(format!("Warning: {warning}"));
74        }
75
76        if !self.deprecation_warnings.is_empty() {
77            messages.push(format!("Migration guide: {MIGRATION_GUIDE_URL}"));
78            messages.push(format!("Extensions docs: {EXTENSIONS_DOC_URL}"));
79        }
80
81        messages
82    }
83}
84
85/// Run one-time startup migrations against the global agent directory.
86#[must_use]
87pub fn run_startup_migrations(cwd: &Path) -> MigrationReport {
88    run_startup_migrations_with_agent_dir(&Config::global_dir(), cwd)
89}
90
91fn run_startup_migrations_with_agent_dir(agent_dir: &Path, cwd: &Path) -> MigrationReport {
92    let mut report = MigrationReport::default();
93
94    report.migrated_auth_providers = migrate_auth_to_auth_json(agent_dir, &mut report.warnings);
95    report.migrated_session_files =
96        migrate_sessions_from_agent_root(agent_dir, &mut report.warnings);
97    report.migrated_tool_binaries = migrate_tools_to_bin(agent_dir, &mut report.warnings);
98
99    if migrate_commands_to_prompts(agent_dir, &mut report.warnings) {
100        report
101            .migrated_commands_dirs
102            .push(agent_dir.join("prompts"));
103    }
104    let project_dir = cwd.join(Config::project_dir());
105    if migrate_commands_to_prompts(&project_dir, &mut report.warnings) {
106        report
107            .migrated_commands_dirs
108            .push(project_dir.join("prompts"));
109    }
110
111    report
112        .deprecation_warnings
113        .extend(check_deprecated_extension_dirs(agent_dir, "Global"));
114    report
115        .deprecation_warnings
116        .extend(check_deprecated_extension_dirs(&project_dir, "Project"));
117
118    report
119}
120
121#[allow(clippy::too_many_lines)]
122fn migrate_auth_to_auth_json(agent_dir: &Path, warnings: &mut Vec<String>) -> Vec<String> {
123    let auth_path = agent_dir.join("auth.json");
124    if auth_path.exists() {
125        return Vec::new();
126    }
127
128    let oauth_path = agent_dir.join("oauth.json");
129    let settings_path = agent_dir.join("settings.json");
130    let mut migrated = Map::new();
131    let mut providers = BTreeSet::new();
132    let mut parsed_oauth = false;
133    let mut oauth_has_unmigrated_entries = false;
134
135    if oauth_path.exists() {
136        match fs::read_to_string(&oauth_path) {
137            Ok(content) => match serde_json::from_str::<Value>(&content) {
138                Ok(Value::Object(entries)) => {
139                    parsed_oauth = true;
140                    for (provider, credential) in entries {
141                        if let Value::Object(mut object) = credential {
142                            object.insert("type".to_string(), Value::String("oauth".to_string()));
143                            migrated.insert(provider.clone(), Value::Object(object));
144                            providers.insert(provider);
145                        } else {
146                            oauth_has_unmigrated_entries = true;
147                            warnings.push(format!(
148                                "oauth.json entry for provider {provider} is not an object; leaving oauth.json in place"
149                            ));
150                        }
151                    }
152                }
153                Ok(_) => warnings
154                    .push("oauth.json is not an object; skipping OAuth migration".to_string()),
155                Err(err) => warnings.push(format!(
156                    "could not parse oauth.json; skipping OAuth migration: {err}"
157                )),
158            },
159            Err(err) => warnings.push(format!(
160                "could not read oauth.json; skipping OAuth migration: {err}"
161            )),
162        }
163    }
164
165    if settings_path.exists() {
166        match fs::read_to_string(&settings_path) {
167            Ok(content) => match serde_json::from_str::<Value>(&content) {
168                Ok(mut settings_value) => {
169                    if let Some(api_keys) = settings_value
170                        .get("apiKeys")
171                        .and_then(Value::as_object)
172                        .cloned()
173                    {
174                        let mut remaining_api_keys = Map::new();
175                        let mut settings_changed = false;
176                        for (provider, key_value) in api_keys {
177                            let Some(key) = key_value.as_str() else {
178                                warnings.push(format!(
179                                        "settings.json apiKeys.{provider} is not a string; leaving it in place"
180                                    ));
181                                remaining_api_keys.insert(provider, key_value);
182                                continue;
183                            };
184                            settings_changed = true;
185                            if migrated.contains_key(&provider) {
186                                continue;
187                            }
188                            migrated.insert(
189                                provider.clone(),
190                                serde_json::json!({
191                                    "type": "api_key",
192                                    "key": key,
193                                }),
194                            );
195                            providers.insert(provider);
196                        }
197                        if settings_changed {
198                            if let Value::Object(settings_obj) = &mut settings_value {
199                                if remaining_api_keys.is_empty() {
200                                    settings_obj.remove("apiKeys");
201                                } else {
202                                    settings_obj.insert(
203                                        "apiKeys".to_string(),
204                                        Value::Object(remaining_api_keys),
205                                    );
206                                }
207                            }
208                            match serde_json::to_string_pretty(&settings_value) {
209                                    Ok(updated) => {
210                                        let tmp = settings_path.with_extension("json.tmp");
211                                        let mut opts = fs::OpenOptions::new();
212                                        opts.write(true).create(true).truncate(true);
213                                        #[cfg(unix)]
214                                        {
215                                            use std::os::unix::fs::OpenOptionsExt;
216                                            opts.mode(0o600);
217                                        }
218                                        let res = opts.open(&tmp).and_then(|mut f| {
219                                            use std::io::Write;
220                                            f.write_all(updated.as_bytes())?;
221                                            f.sync_all()
222                                        }).and_then(|()| fs::rename(&tmp, &settings_path));
223
224                                        if let Err(err) = res {
225                                            warnings.push(format!(
226                                                "could not persist settings.json after apiKeys migration: {err}"
227                                            ));
228                                        }
229                                    }
230                                    Err(err) => warnings.push(format!(
231                                        "could not serialize settings.json after apiKeys migration: {err}"
232                                    )),
233                                }
234                        }
235                    }
236                }
237                Err(err) => warnings.push(format!(
238                    "could not parse settings.json for apiKeys migration: {err}"
239                )),
240            },
241            Err(err) => warnings.push(format!(
242                "could not read settings.json for apiKeys migration: {err}"
243            )),
244        }
245    }
246
247    let mut auth_persisted = migrated.is_empty();
248    if !migrated.is_empty() {
249        if let Err(err) = fs::create_dir_all(agent_dir) {
250            warnings.push(format!(
251                "could not create agent dir for auth.json migration: {err}"
252            ));
253            return providers.into_iter().collect();
254        }
255
256        match serde_json::to_string_pretty(&Value::Object(migrated)) {
257            Ok(contents) => {
258                let tmp = auth_path.with_extension("json.tmp");
259                let mut options = std::fs::OpenOptions::new();
260                options.write(true).create(true).truncate(true);
261                #[cfg(unix)]
262                {
263                    use std::os::unix::fs::OpenOptionsExt;
264                    options.mode(0o600);
265                }
266
267                let res = options
268                    .open(&tmp)
269                    .and_then(|mut f| {
270                        use std::io::Write;
271                        f.write_all(contents.as_bytes())?;
272                        f.sync_all()
273                    })
274                    .and_then(|()| fs::rename(&tmp, &auth_path));
275
276                if let Err(err) = res {
277                    warnings.push(format!("could not write auth.json during migration: {err}"));
278                } else if let Err(err) = set_owner_only_permissions(&auth_path) {
279                    warnings.push(format!("could not set auth.json permissions to 600: {err}"));
280                } else {
281                    auth_persisted = true;
282                }
283            }
284            Err(err) => warnings.push(format!("could not serialize migrated auth.json: {err}")),
285        }
286    }
287
288    if parsed_oauth && !oauth_has_unmigrated_entries && auth_persisted && oauth_path.exists() {
289        let migrated_path = oauth_path.with_extension("json.migrated");
290        if let Err(err) = fs::rename(&oauth_path, migrated_path) {
291            warnings.push(format!(
292                "could not rename oauth.json after migration: {err}"
293            ));
294        }
295    }
296
297    providers.into_iter().collect()
298}
299
300fn migrate_sessions_from_agent_root(agent_dir: &Path, warnings: &mut Vec<String>) -> usize {
301    let Ok(read_dir) = fs::read_dir(agent_dir) else {
302        return 0;
303    };
304
305    let mut migrated_count = 0usize;
306
307    for entry in read_dir.flatten() {
308        let Ok(file_type) = entry.file_type() else {
309            continue;
310        };
311        if !file_type.is_file() {
312            continue;
313        }
314        let source_path = entry.path();
315        if source_path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
316            continue;
317        }
318
319        let Some(cwd) = session_cwd_from_header(&source_path) else {
320            continue;
321        };
322        let encoded = encode_cwd(Path::new(&cwd));
323        let target_dir = agent_dir.join("sessions").join(encoded);
324        if let Err(err) = fs::create_dir_all(&target_dir) {
325            warnings.push(format!(
326                "could not create session migration target dir {}: {err}",
327                target_dir.display()
328            ));
329            continue;
330        }
331        let Some(file_name) = source_path.file_name() else {
332            continue;
333        };
334        let target_path = target_dir.join(file_name);
335        if target_path.exists() {
336            continue;
337        }
338        if let Err(err) = fs::rename(&source_path, &target_path) {
339            warnings.push(format!(
340                "could not migrate session file {} to {}: {err}",
341                source_path.display(),
342                target_path.display()
343            ));
344            continue;
345        }
346        migrated_count += 1;
347    }
348
349    migrated_count
350}
351
352fn session_cwd_from_header(path: &Path) -> Option<String> {
353    let file = File::open(path).ok()?;
354    let mut reader = BufReader::new(file);
355    let mut line = String::new();
356    if reader.read_line(&mut line).ok()? == 0 {
357        return None;
358    }
359    let header: Value = serde_json::from_str(line.trim()).ok()?;
360    if header.get("type").and_then(Value::as_str) != Some("session") {
361        return None;
362    }
363    header
364        .get("cwd")
365        .and_then(Value::as_str)
366        .map(ToOwned::to_owned)
367}
368
369fn migrate_commands_to_prompts(base_dir: &Path, warnings: &mut Vec<String>) -> bool {
370    let commands_dir = base_dir.join("commands");
371    let prompts_dir = base_dir.join("prompts");
372    if !commands_dir.exists() || prompts_dir.exists() {
373        return false;
374    }
375
376    match fs::rename(&commands_dir, &prompts_dir) {
377        Ok(()) => true,
378        Err(err) => {
379            warnings.push(format!(
380                "could not migrate commands/ to prompts/ in {}: {err}",
381                base_dir.display()
382            ));
383            false
384        }
385    }
386}
387
388fn migrate_tools_to_bin(agent_dir: &Path, warnings: &mut Vec<String>) -> Vec<String> {
389    let tools_dir = agent_dir.join("tools");
390    if !tools_dir.exists() {
391        return Vec::new();
392    }
393    let bin_dir = agent_dir.join("bin");
394    let mut moved = Vec::new();
395
396    for binary in MANAGED_TOOL_BINARIES {
397        let old_path = tools_dir.join(binary);
398        if !old_path.exists() {
399            continue;
400        }
401
402        if let Err(err) = fs::create_dir_all(&bin_dir) {
403            warnings.push(format!("could not create bin/ directory: {err}"));
404            break;
405        }
406
407        let new_path = bin_dir.join(binary);
408        if new_path.exists() {
409            if let Err(err) = fs::remove_file(&old_path) {
410                warnings.push(format!(
411                    "could not remove legacy managed binary {} after migration: {err}",
412                    old_path.display()
413                ));
414            }
415            continue;
416        }
417
418        match fs::rename(&old_path, &new_path) {
419            Ok(()) => moved.push((*binary).to_string()),
420            Err(err) => warnings.push(format!(
421                "could not move managed binary {} to {}: {err}",
422                old_path.display(),
423                new_path.display()
424            )),
425        }
426    }
427
428    moved
429}
430
431fn check_deprecated_extension_dirs(base_dir: &Path, label: &str) -> Vec<String> {
432    let mut warnings = Vec::new();
433
434    let hooks_dir = base_dir.join("hooks");
435    if hooks_dir.exists() {
436        warnings.push(format!(
437            "{label} hooks/ directory found. Hooks have been renamed to extensions/"
438        ));
439    }
440
441    let tools_dir = base_dir.join("tools");
442    if tools_dir.exists() {
443        match fs::read_dir(&tools_dir) {
444            Ok(entries) => {
445                let custom_entries = entries
446                    .flatten()
447                    .filter(|entry| {
448                        let name = entry.file_name().to_string_lossy().to_string();
449                        if name.starts_with('.') {
450                            return false;
451                        }
452                        !MANAGED_TOOL_BINARIES.iter().any(|managed| *managed == name)
453                    })
454                    .count();
455                if custom_entries > 0 {
456                    warnings.push(format!(
457                        "{label} tools/ directory contains custom files. Custom tools should live under extensions/"
458                    ));
459                }
460            }
461            Err(err) => warnings.push(format!(
462                "could not inspect deprecated tools/ directory at {}: {err}",
463                tools_dir.display()
464            )),
465        }
466    }
467
468    warnings
469}
470
471fn set_owner_only_permissions(path: &Path) -> std::io::Result<()> {
472    #[cfg(unix)]
473    {
474        use std::os::unix::fs::PermissionsExt;
475        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
476    }
477    #[cfg(not(unix))]
478    {
479        let _ = path;
480        Ok(())
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::run_startup_migrations_with_agent_dir;
487    use crate::session::encode_cwd;
488    use serde_json::Value;
489    use std::fs;
490    use tempfile::TempDir;
491
492    fn write(path: &std::path::Path, content: &str) {
493        if let Some(parent) = path.parent() {
494            fs::create_dir_all(parent).expect("create parent directory");
495        }
496        fs::write(path, content).expect("write fixture file");
497    }
498
499    #[test]
500    fn migrate_auth_from_oauth_and_settings_api_keys() {
501        let temp = TempDir::new().expect("tempdir");
502        let agent_dir = temp.path().join("agent");
503        let cwd = temp.path().join("project");
504        fs::create_dir_all(&agent_dir).expect("create agent dir");
505        fs::create_dir_all(&cwd).expect("create cwd");
506
507        write(
508            &agent_dir.join("oauth.json"),
509            r#"{"anthropic":{"access_token":"a","refresh_token":"r","expires":1}}"#,
510        );
511        write(
512            &agent_dir.join("settings.json"),
513            r#"{"apiKeys":{"openai":"sk-openai","anthropic":"ignored"},"theme":"dark"}"#,
514        );
515
516        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
517        assert_eq!(
518            report.migrated_auth_providers,
519            vec!["anthropic".to_string(), "openai".to_string()]
520        );
521
522        let auth_value: Value = serde_json::from_str(
523            &fs::read_to_string(agent_dir.join("auth.json")).expect("read auth"),
524        )
525        .expect("parse auth");
526        assert_eq!(auth_value["anthropic"]["type"], "oauth");
527        assert_eq!(auth_value["openai"]["type"], "api_key");
528        assert_eq!(auth_value["openai"]["key"], "sk-openai");
529
530        let settings_value: Value = serde_json::from_str(
531            &fs::read_to_string(agent_dir.join("settings.json")).expect("read settings"),
532        )
533        .expect("parse settings");
534        assert!(settings_value.get("apiKeys").is_none());
535        assert!(agent_dir.join("oauth.json.migrated").exists());
536    }
537
538    #[test]
539    fn migrate_auth_preserves_malformed_oauth_entries() {
540        let temp = TempDir::new().expect("tempdir");
541        let agent_dir = temp.path().join("agent");
542        let cwd = temp.path().join("project");
543        fs::create_dir_all(&agent_dir).expect("create agent dir");
544        fs::create_dir_all(&cwd).expect("create cwd");
545
546        write(
547            &agent_dir.join("oauth.json"),
548            r#"{"anthropic":{"access_token":"a","refresh_token":"r","expires":1},"broken":"oops"}"#,
549        );
550
551        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
552        assert_eq!(
553            report.migrated_auth_providers,
554            vec!["anthropic".to_string()]
555        );
556        assert!(report.warnings.iter().any(|warning| {
557            warning.contains("oauth.json entry for provider broken is not an object")
558        }));
559
560        let auth_value: Value = serde_json::from_str(
561            &fs::read_to_string(agent_dir.join("auth.json")).expect("read auth"),
562        )
563        .expect("parse auth");
564        assert_eq!(auth_value["anthropic"]["type"], "oauth");
565        assert!(agent_dir.join("oauth.json").exists());
566        assert!(!agent_dir.join("oauth.json.migrated").exists());
567    }
568
569    #[test]
570    fn migrate_auth_preserves_invalid_settings_api_keys() {
571        let temp = TempDir::new().expect("tempdir");
572        let agent_dir = temp.path().join("agent");
573        let cwd = temp.path().join("project");
574        fs::create_dir_all(&agent_dir).expect("create agent dir");
575        fs::create_dir_all(&cwd).expect("create cwd");
576
577        write(
578            &agent_dir.join("settings.json"),
579            r#"{"apiKeys":{"openai":"sk-openai","broken":123},"theme":"dark"}"#,
580        );
581
582        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
583        assert_eq!(report.migrated_auth_providers, vec!["openai".to_string()]);
584        assert!(
585            report.warnings.iter().any(|warning| {
586                warning.contains("settings.json apiKeys.broken is not a string")
587            })
588        );
589
590        let auth_value: Value = serde_json::from_str(
591            &fs::read_to_string(agent_dir.join("auth.json")).expect("read auth"),
592        )
593        .expect("parse auth");
594        assert_eq!(auth_value["openai"]["type"], "api_key");
595        assert_eq!(auth_value["openai"]["key"], "sk-openai");
596
597        let settings_value: Value = serde_json::from_str(
598            &fs::read_to_string(agent_dir.join("settings.json")).expect("read settings"),
599        )
600        .expect("parse settings");
601        assert_eq!(settings_value["theme"], "dark");
602        assert_eq!(settings_value["apiKeys"]["broken"], 123);
603        assert!(settings_value["apiKeys"].get("openai").is_none());
604    }
605
606    #[cfg(unix)]
607    #[test]
608    fn migrate_auth_sets_owner_only_permissions() {
609        use std::os::unix::fs::PermissionsExt;
610
611        let temp = TempDir::new().expect("tempdir");
612        let agent_dir = temp.path().join("agent");
613        let cwd = temp.path().join("project");
614        fs::create_dir_all(&agent_dir).expect("create agent dir");
615        fs::create_dir_all(&cwd).expect("create cwd");
616
617        write(
618            &agent_dir.join("settings.json"),
619            r#"{"apiKeys":{"openai":"sk-test"}}"#,
620        );
621
622        let _report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
623
624        let auth_path = agent_dir.join("auth.json");
625        assert!(auth_path.exists(), "auth.json should be created");
626        let mode = fs::metadata(&auth_path)
627            .expect("metadata")
628            .permissions()
629            .mode();
630        assert_eq!(
631            mode & 0o777,
632            0o600,
633            "auth.json should have 0o600 permissions, got {mode:#o}"
634        );
635    }
636
637    #[test]
638    fn migrate_sessions_from_agent_root_to_encoded_project_dir() {
639        let temp = TempDir::new().expect("tempdir");
640        let agent_dir = temp.path().join("agent");
641        let cwd = temp.path().join("workspace");
642        fs::create_dir_all(&agent_dir).expect("create agent dir");
643        fs::create_dir_all(&cwd).expect("create cwd");
644
645        write(
646            &agent_dir.join("legacy-session.jsonl"),
647            &format!(
648                "{{\"type\":\"session\",\"cwd\":\"{}\",\"id\":\"abc\"}}\n{{\"type\":\"message\"}}\n",
649                cwd.display()
650            ),
651        );
652
653        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
654        assert_eq!(report.migrated_session_files, 1);
655
656        let expected = agent_dir
657            .join("sessions")
658            .join(encode_cwd(&cwd))
659            .join("legacy-session.jsonl");
660        assert!(expected.exists());
661        assert!(!agent_dir.join("legacy-session.jsonl").exists());
662    }
663
664    #[test]
665    fn migrate_commands_and_managed_tools() {
666        let temp = TempDir::new().expect("tempdir");
667        let agent_dir = temp.path().join("agent");
668        let cwd = temp.path().join("workspace");
669        let project_dir = cwd.join(".pi");
670        fs::create_dir_all(&agent_dir).expect("create agent dir");
671        fs::create_dir_all(&project_dir).expect("create project dir");
672
673        write(&agent_dir.join("commands/global.md"), "# global");
674        write(&project_dir.join("commands/project.md"), "# project");
675        write(&agent_dir.join("tools/fd"), "fd-binary");
676        write(&agent_dir.join("tools/rg"), "rg-binary");
677
678        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
679
680        assert!(agent_dir.join("prompts/global.md").exists());
681        assert!(project_dir.join("prompts/project.md").exists());
682        assert!(agent_dir.join("bin/fd").exists());
683        assert!(agent_dir.join("bin/rg").exists());
684        assert!(!agent_dir.join("tools/fd").exists());
685        assert!(!agent_dir.join("tools/rg").exists());
686        assert_eq!(report.migrated_tool_binaries.len(), 2);
687        assert_eq!(report.migrated_commands_dirs.len(), 2);
688    }
689
690    #[test]
691    fn managed_tool_cleanup_when_target_exists() {
692        let temp = TempDir::new().expect("tempdir");
693        let agent_dir = temp.path().join("agent");
694        let cwd = temp.path().join("workspace");
695        fs::create_dir_all(&agent_dir).expect("create agent dir");
696        fs::create_dir_all(&cwd).expect("create cwd");
697
698        write(&agent_dir.join("tools/fd"), "legacy-fd");
699        write(&agent_dir.join("bin/fd"), "existing-fd");
700
701        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
702        assert!(report.migrated_tool_binaries.is_empty());
703        assert!(!agent_dir.join("tools/fd").exists());
704        assert_eq!(
705            fs::read_to_string(agent_dir.join("bin/fd")).expect("read existing bin/fd"),
706            "existing-fd"
707        );
708    }
709
710    #[test]
711    fn warns_for_deprecated_hooks_and_custom_tools() {
712        let temp = TempDir::new().expect("tempdir");
713        let agent_dir = temp.path().join("agent");
714        let cwd = temp.path().join("workspace");
715        let project_dir = cwd.join(".pi");
716        fs::create_dir_all(agent_dir.join("hooks")).expect("create global hooks");
717        fs::create_dir_all(project_dir.join("hooks")).expect("create project hooks");
718        write(&agent_dir.join("tools/custom.sh"), "#!/bin/sh\necho hi\n");
719
720        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
721        assert!(!report.deprecation_warnings.is_empty());
722        assert!(
723            report
724                .messages()
725                .iter()
726                .any(|line| line.contains("Migration guide: "))
727        );
728    }
729
730    #[test]
731    fn migration_is_idempotent() {
732        let temp = TempDir::new().expect("tempdir");
733        let agent_dir = temp.path().join("agent");
734        let cwd = temp.path().join("workspace");
735        fs::create_dir_all(&agent_dir).expect("create agent dir");
736        fs::create_dir_all(&cwd).expect("create cwd");
737
738        write(
739            &agent_dir.join("oauth.json"),
740            r#"{"anthropic":{"access_token":"a","refresh_token":"r","expires":1}}"#,
741        );
742        write(
743            &agent_dir.join("legacy.jsonl"),
744            &format!("{{\"type\":\"session\",\"cwd\":\"{}\"}}\n", cwd.display()),
745        );
746        write(&agent_dir.join("commands/hello.md"), "# hello");
747        write(&agent_dir.join("tools/fd"), "fd-binary");
748
749        let first = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
750        assert!(!first.migrated_auth_providers.is_empty());
751        assert!(first.migrated_session_files > 0);
752
753        let second = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
754        assert!(second.migrated_auth_providers.is_empty());
755        assert_eq!(second.migrated_session_files, 0);
756        assert!(second.migrated_commands_dirs.is_empty());
757        assert!(second.migrated_tool_binaries.is_empty());
758    }
759
760    #[test]
761    fn empty_layout_is_noop() {
762        let temp = TempDir::new().expect("tempdir");
763        let agent_dir = temp.path().join("agent");
764        let cwd = temp.path().join("workspace");
765        fs::create_dir_all(&cwd).expect("create cwd");
766
767        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
768        assert!(report.migrated_auth_providers.is_empty());
769        assert_eq!(report.migrated_session_files, 0);
770        assert!(report.migrated_commands_dirs.is_empty());
771        assert!(report.migrated_tool_binaries.is_empty());
772        assert!(report.deprecation_warnings.is_empty());
773        assert!(report.warnings.is_empty());
774    }
775
776    mod proptest_migrations {
777        use crate::migrations::{MigrationReport, session_cwd_from_header};
778        use proptest::prelude::*;
779
780        proptest! {
781            /// Empty `MigrationReport` produces empty messages.
782            #[test]
783            fn empty_report_no_messages(_dummy in 0..1u8) {
784                let report = MigrationReport::default();
785                assert!(report.messages().is_empty());
786            }
787
788            /// Auth provider migration message includes all provider names.
789            #[test]
790            fn messages_include_providers(
791                p1 in "[a-z]{3,8}",
792                p2 in "[a-z]{3,8}"
793            ) {
794                let report = MigrationReport {
795                    migrated_auth_providers: vec![p1.clone(), p2.clone()],
796                    ..Default::default()
797                };
798                let msgs = report.messages();
799                assert_eq!(msgs.len(), 1);
800                assert!(msgs[0].contains(&p1));
801                assert!(msgs[0].contains(&p2));
802            }
803
804            /// Session migration message includes count.
805            #[test]
806            fn messages_include_session_count(count in 1..100usize) {
807                let report = MigrationReport {
808                    migrated_session_files: count,
809                    ..Default::default()
810                };
811                let msgs = report.messages();
812                assert_eq!(msgs.len(), 1);
813                assert!(msgs[0].contains(&count.to_string()));
814            }
815
816            /// Warnings are prefixed with "Warning: ".
817            #[test]
818            fn messages_prefix_warnings(warning in "[a-z ]{5,20}") {
819                let report = MigrationReport {
820                    warnings: vec![warning.clone()],
821                    ..Default::default()
822                };
823                let msgs = report.messages();
824                assert_eq!(msgs.len(), 1);
825                assert!(msgs[0].starts_with("Warning: "));
826                assert!(msgs[0].contains(&warning));
827            }
828
829            /// Deprecation warnings add guide/docs URLs.
830            #[test]
831            fn messages_deprecation_adds_urls(warning in "[a-z ]{5,20}") {
832                let report = MigrationReport {
833                    deprecation_warnings: vec![warning],
834                    ..Default::default()
835                };
836                let msgs = report.messages();
837                // warning + guide URL + docs URL
838                assert_eq!(msgs.len(), 3);
839                assert!(msgs[1].contains("Migration guide:"));
840                assert!(msgs[2].contains("Extensions docs:"));
841            }
842
843            /// `session_cwd_from_header` extracts cwd from valid session header.
844            #[test]
845            fn session_cwd_extraction(cwd in "[/a-z]{3,20}") {
846                let dir = tempfile::tempdir().unwrap();
847                let path = dir.path().join("test.jsonl");
848                let header = serde_json::json!({
849                    "type": "session",
850                    "cwd": cwd,
851                    "id": "test"
852                });
853                std::fs::write(&path, serde_json::to_string(&header).unwrap()).unwrap();
854                assert_eq!(session_cwd_from_header(&path), Some(cwd));
855            }
856
857            /// `session_cwd_from_header` returns None for wrong type.
858            #[test]
859            fn session_cwd_wrong_type(type_val in "[a-z]{3,10}") {
860                prop_assume!(type_val != "session");
861                let dir = tempfile::tempdir().unwrap();
862                let path = dir.path().join("test.jsonl");
863                let header = serde_json::json!({
864                    "type": type_val,
865                    "cwd": "/test"
866                });
867                std::fs::write(&path, serde_json::to_string(&header).unwrap()).unwrap();
868                assert_eq!(session_cwd_from_header(&path), None);
869            }
870
871            /// `session_cwd_from_header` returns None for empty file.
872            #[test]
873            fn session_cwd_empty_file(_dummy in 0..1u8) {
874                let dir = tempfile::tempdir().unwrap();
875                let path = dir.path().join("empty.jsonl");
876                std::fs::write(&path, "").unwrap();
877                assert_eq!(session_cwd_from_header(&path), None);
878            }
879
880            /// `session_cwd_from_header` returns None for invalid JSON.
881            #[test]
882            fn session_cwd_invalid_json(s in "[a-z]{5,20}") {
883                let dir = tempfile::tempdir().unwrap();
884                let path = dir.path().join("bad.jsonl");
885                std::fs::write(&path, &s).unwrap();
886                assert_eq!(session_cwd_from_header(&path), None);
887            }
888
889            /// Message count equals sum of non-empty field contributions.
890            #[test]
891            fn messages_count_additive(
892                n_providers in 0..3usize,
893                sessions in 0..5usize,
894                n_warnings in 0..3usize,
895                n_deprecations in 0..3usize
896            ) {
897                let report = MigrationReport {
898                    migrated_auth_providers: (0..n_providers).map(|i| format!("p{i}")).collect(),
899                    migrated_session_files: sessions,
900                    migrated_commands_dirs: Vec::new(),
901                    migrated_tool_binaries: Vec::new(),
902                    warnings: (0..n_warnings).map(|i| format!("w{i}")).collect(),
903                    deprecation_warnings: (0..n_deprecations).map(|i| format!("d{i}")).collect(),
904                };
905                let msgs = report.messages();
906                let mut expected = 0;
907                if n_providers > 0 { expected += 1; }
908                if sessions > 0 { expected += 1; }
909                expected += n_warnings;
910                expected += n_deprecations;
911                if n_deprecations > 0 { expected += 2; } // guide + docs URLs
912                assert_eq!(msgs.len(), expected);
913            }
914        }
915    }
916}