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
134    if oauth_path.exists() {
135        match fs::read_to_string(&oauth_path) {
136            Ok(content) => match serde_json::from_str::<Value>(&content) {
137                Ok(Value::Object(entries)) => {
138                    parsed_oauth = true;
139                    for (provider, credential) in entries {
140                        if let Value::Object(mut object) = credential {
141                            object.insert("type".to_string(), Value::String("oauth".to_string()));
142                            migrated.insert(provider.clone(), Value::Object(object));
143                            providers.insert(provider);
144                        }
145                    }
146                }
147                Ok(_) => warnings
148                    .push("oauth.json is not an object; skipping OAuth migration".to_string()),
149                Err(err) => warnings.push(format!(
150                    "could not parse oauth.json; skipping OAuth migration: {err}"
151                )),
152            },
153            Err(err) => warnings.push(format!(
154                "could not read oauth.json; skipping OAuth migration: {err}"
155            )),
156        }
157    }
158
159    if settings_path.exists() {
160        match fs::read_to_string(&settings_path) {
161            Ok(content) => match serde_json::from_str::<Value>(&content) {
162                Ok(mut settings_value) => {
163                    if let Some(api_keys) = settings_value
164                        .get("apiKeys")
165                        .and_then(Value::as_object)
166                        .cloned()
167                    {
168                        for (provider, key_value) in api_keys {
169                            let Some(key) = key_value.as_str() else {
170                                continue;
171                            };
172                            if migrated.contains_key(&provider) {
173                                continue;
174                            }
175                            migrated.insert(
176                                provider.clone(),
177                                serde_json::json!({
178                                    "type": "api_key",
179                                    "key": key,
180                                }),
181                            );
182                            providers.insert(provider);
183                        }
184                        if let Value::Object(settings_obj) = &mut settings_value {
185                            settings_obj.remove("apiKeys");
186                        }
187                        match serde_json::to_string_pretty(&settings_value) {
188                            Ok(updated) => {
189                                if let Err(err) = fs::write(&settings_path, updated) {
190                                    warnings.push(format!(
191                                        "could not persist settings.json after apiKeys migration: {err}"
192                                    ));
193                                }
194                            }
195                            Err(err) => warnings.push(format!(
196                                "could not serialize settings.json after apiKeys migration: {err}"
197                            )),
198                        }
199                    }
200                }
201                Err(err) => warnings.push(format!(
202                    "could not parse settings.json for apiKeys migration: {err}"
203                )),
204            },
205            Err(err) => warnings.push(format!(
206                "could not read settings.json for apiKeys migration: {err}"
207            )),
208        }
209    }
210
211    let mut auth_persisted = migrated.is_empty();
212    if !migrated.is_empty() {
213        if let Err(err) = fs::create_dir_all(agent_dir) {
214            warnings.push(format!(
215                "could not create agent dir for auth.json migration: {err}"
216            ));
217            return providers.into_iter().collect();
218        }
219
220        match serde_json::to_string_pretty(&Value::Object(migrated)) {
221            Ok(contents) => {
222                if let Err(err) = fs::write(&auth_path, contents) {
223                    warnings.push(format!("could not write auth.json during migration: {err}"));
224                } else if let Err(err) = set_owner_only_permissions(&auth_path) {
225                    warnings.push(format!("could not set auth.json permissions to 600: {err}"));
226                } else {
227                    auth_persisted = true;
228                }
229            }
230            Err(err) => warnings.push(format!("could not serialize migrated auth.json: {err}")),
231        }
232    }
233
234    if parsed_oauth && auth_persisted && oauth_path.exists() {
235        let migrated_path = oauth_path.with_extension("json.migrated");
236        if let Err(err) = fs::rename(&oauth_path, migrated_path) {
237            warnings.push(format!(
238                "could not rename oauth.json after migration: {err}"
239            ));
240        }
241    }
242
243    providers.into_iter().collect()
244}
245
246fn migrate_sessions_from_agent_root(agent_dir: &Path, warnings: &mut Vec<String>) -> usize {
247    let Ok(read_dir) = fs::read_dir(agent_dir) else {
248        return 0;
249    };
250
251    let mut migrated_count = 0usize;
252
253    for entry in read_dir.flatten() {
254        let Ok(file_type) = entry.file_type() else {
255            continue;
256        };
257        if !file_type.is_file() {
258            continue;
259        }
260        let source_path = entry.path();
261        if source_path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
262            continue;
263        }
264
265        let Some(cwd) = session_cwd_from_header(&source_path) else {
266            continue;
267        };
268        let encoded = encode_cwd(Path::new(&cwd));
269        let target_dir = agent_dir.join("sessions").join(encoded);
270        if let Err(err) = fs::create_dir_all(&target_dir) {
271            warnings.push(format!(
272                "could not create session migration target dir {}: {err}",
273                target_dir.display()
274            ));
275            continue;
276        }
277        let Some(file_name) = source_path.file_name() else {
278            continue;
279        };
280        let target_path = target_dir.join(file_name);
281        if target_path.exists() {
282            continue;
283        }
284        if let Err(err) = fs::rename(&source_path, &target_path) {
285            warnings.push(format!(
286                "could not migrate session file {} to {}: {err}",
287                source_path.display(),
288                target_path.display()
289            ));
290            continue;
291        }
292        migrated_count += 1;
293    }
294
295    migrated_count
296}
297
298fn session_cwd_from_header(path: &Path) -> Option<String> {
299    let file = File::open(path).ok()?;
300    let mut reader = BufReader::new(file);
301    let mut line = String::new();
302    if reader.read_line(&mut line).ok()? == 0 {
303        return None;
304    }
305    let header: Value = serde_json::from_str(line.trim()).ok()?;
306    if header.get("type").and_then(Value::as_str) != Some("session") {
307        return None;
308    }
309    header
310        .get("cwd")
311        .and_then(Value::as_str)
312        .map(ToOwned::to_owned)
313}
314
315fn migrate_commands_to_prompts(base_dir: &Path, warnings: &mut Vec<String>) -> bool {
316    let commands_dir = base_dir.join("commands");
317    let prompts_dir = base_dir.join("prompts");
318    if !commands_dir.exists() || prompts_dir.exists() {
319        return false;
320    }
321
322    match fs::rename(&commands_dir, &prompts_dir) {
323        Ok(()) => true,
324        Err(err) => {
325            warnings.push(format!(
326                "could not migrate commands/ to prompts/ in {}: {err}",
327                base_dir.display()
328            ));
329            false
330        }
331    }
332}
333
334fn migrate_tools_to_bin(agent_dir: &Path, warnings: &mut Vec<String>) -> Vec<String> {
335    let tools_dir = agent_dir.join("tools");
336    if !tools_dir.exists() {
337        return Vec::new();
338    }
339    let bin_dir = agent_dir.join("bin");
340    let mut moved = Vec::new();
341
342    for binary in MANAGED_TOOL_BINARIES {
343        let old_path = tools_dir.join(binary);
344        if !old_path.exists() {
345            continue;
346        }
347
348        if let Err(err) = fs::create_dir_all(&bin_dir) {
349            warnings.push(format!("could not create bin/ directory: {err}"));
350            break;
351        }
352
353        let new_path = bin_dir.join(binary);
354        if new_path.exists() {
355            if let Err(err) = fs::remove_file(&old_path) {
356                warnings.push(format!(
357                    "could not remove legacy managed binary {} after migration: {err}",
358                    old_path.display()
359                ));
360            }
361            continue;
362        }
363
364        match fs::rename(&old_path, &new_path) {
365            Ok(()) => moved.push((*binary).to_string()),
366            Err(err) => warnings.push(format!(
367                "could not move managed binary {} to {}: {err}",
368                old_path.display(),
369                new_path.display()
370            )),
371        }
372    }
373
374    moved
375}
376
377fn check_deprecated_extension_dirs(base_dir: &Path, label: &str) -> Vec<String> {
378    let mut warnings = Vec::new();
379
380    let hooks_dir = base_dir.join("hooks");
381    if hooks_dir.exists() {
382        warnings.push(format!(
383            "{label} hooks/ directory found. Hooks have been renamed to extensions/"
384        ));
385    }
386
387    let tools_dir = base_dir.join("tools");
388    if tools_dir.exists() {
389        match fs::read_dir(&tools_dir) {
390            Ok(entries) => {
391                let custom_entries = entries
392                    .flatten()
393                    .filter(|entry| {
394                        let name = entry.file_name().to_string_lossy().to_string();
395                        if name.starts_with('.') {
396                            return false;
397                        }
398                        !MANAGED_TOOL_BINARIES.iter().any(|managed| *managed == name)
399                    })
400                    .count();
401                if custom_entries > 0 {
402                    warnings.push(format!(
403                        "{label} tools/ directory contains custom files. Custom tools should live under extensions/"
404                    ));
405                }
406            }
407            Err(err) => warnings.push(format!(
408                "could not inspect deprecated tools/ directory at {}: {err}",
409                tools_dir.display()
410            )),
411        }
412    }
413
414    warnings
415}
416
417fn set_owner_only_permissions(path: &Path) -> std::io::Result<()> {
418    #[cfg(unix)]
419    {
420        use std::os::unix::fs::PermissionsExt;
421        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
422    }
423    #[cfg(not(unix))]
424    {
425        let _ = path;
426        Ok(())
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::run_startup_migrations_with_agent_dir;
433    use crate::session::encode_cwd;
434    use serde_json::Value;
435    use std::fs;
436    use tempfile::TempDir;
437
438    fn write(path: &std::path::Path, content: &str) {
439        if let Some(parent) = path.parent() {
440            fs::create_dir_all(parent).expect("create parent directory");
441        }
442        fs::write(path, content).expect("write fixture file");
443    }
444
445    #[test]
446    fn migrate_auth_from_oauth_and_settings_api_keys() {
447        let temp = TempDir::new().expect("tempdir");
448        let agent_dir = temp.path().join("agent");
449        let cwd = temp.path().join("project");
450        fs::create_dir_all(&agent_dir).expect("create agent dir");
451        fs::create_dir_all(&cwd).expect("create cwd");
452
453        write(
454            &agent_dir.join("oauth.json"),
455            r#"{"anthropic":{"access_token":"a","refresh_token":"r","expires":1}}"#,
456        );
457        write(
458            &agent_dir.join("settings.json"),
459            r#"{"apiKeys":{"openai":"sk-openai","anthropic":"ignored"},"theme":"dark"}"#,
460        );
461
462        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
463        assert_eq!(
464            report.migrated_auth_providers,
465            vec!["anthropic".to_string(), "openai".to_string()]
466        );
467
468        let auth_value: Value = serde_json::from_str(
469            &fs::read_to_string(agent_dir.join("auth.json")).expect("read auth"),
470        )
471        .expect("parse auth");
472        assert_eq!(auth_value["anthropic"]["type"], "oauth");
473        assert_eq!(auth_value["openai"]["type"], "api_key");
474        assert_eq!(auth_value["openai"]["key"], "sk-openai");
475
476        let settings_value: Value = serde_json::from_str(
477            &fs::read_to_string(agent_dir.join("settings.json")).expect("read settings"),
478        )
479        .expect("parse settings");
480        assert!(settings_value.get("apiKeys").is_none());
481        assert!(agent_dir.join("oauth.json.migrated").exists());
482    }
483
484    #[test]
485    fn migrate_sessions_from_agent_root_to_encoded_project_dir() {
486        let temp = TempDir::new().expect("tempdir");
487        let agent_dir = temp.path().join("agent");
488        let cwd = temp.path().join("workspace");
489        fs::create_dir_all(&agent_dir).expect("create agent dir");
490        fs::create_dir_all(&cwd).expect("create cwd");
491
492        write(
493            &agent_dir.join("legacy-session.jsonl"),
494            &format!(
495                "{{\"type\":\"session\",\"cwd\":\"{}\",\"id\":\"abc\"}}\n{{\"type\":\"message\"}}\n",
496                cwd.display()
497            ),
498        );
499
500        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
501        assert_eq!(report.migrated_session_files, 1);
502
503        let expected = agent_dir
504            .join("sessions")
505            .join(encode_cwd(&cwd))
506            .join("legacy-session.jsonl");
507        assert!(expected.exists());
508        assert!(!agent_dir.join("legacy-session.jsonl").exists());
509    }
510
511    #[test]
512    fn migrate_commands_and_managed_tools() {
513        let temp = TempDir::new().expect("tempdir");
514        let agent_dir = temp.path().join("agent");
515        let cwd = temp.path().join("workspace");
516        let project_dir = cwd.join(".pi");
517        fs::create_dir_all(&agent_dir).expect("create agent dir");
518        fs::create_dir_all(&project_dir).expect("create project dir");
519
520        write(&agent_dir.join("commands/global.md"), "# global");
521        write(&project_dir.join("commands/project.md"), "# project");
522        write(&agent_dir.join("tools/fd"), "fd-binary");
523        write(&agent_dir.join("tools/rg"), "rg-binary");
524
525        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
526
527        assert!(agent_dir.join("prompts/global.md").exists());
528        assert!(project_dir.join("prompts/project.md").exists());
529        assert!(agent_dir.join("bin/fd").exists());
530        assert!(agent_dir.join("bin/rg").exists());
531        assert!(!agent_dir.join("tools/fd").exists());
532        assert!(!agent_dir.join("tools/rg").exists());
533        assert_eq!(report.migrated_tool_binaries.len(), 2);
534        assert_eq!(report.migrated_commands_dirs.len(), 2);
535    }
536
537    #[test]
538    fn managed_tool_cleanup_when_target_exists() {
539        let temp = TempDir::new().expect("tempdir");
540        let agent_dir = temp.path().join("agent");
541        let cwd = temp.path().join("workspace");
542        fs::create_dir_all(&agent_dir).expect("create agent dir");
543        fs::create_dir_all(&cwd).expect("create cwd");
544
545        write(&agent_dir.join("tools/fd"), "legacy-fd");
546        write(&agent_dir.join("bin/fd"), "existing-fd");
547
548        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
549        assert!(report.migrated_tool_binaries.is_empty());
550        assert!(!agent_dir.join("tools/fd").exists());
551        assert_eq!(
552            fs::read_to_string(agent_dir.join("bin/fd")).expect("read existing bin/fd"),
553            "existing-fd"
554        );
555    }
556
557    #[test]
558    fn warns_for_deprecated_hooks_and_custom_tools() {
559        let temp = TempDir::new().expect("tempdir");
560        let agent_dir = temp.path().join("agent");
561        let cwd = temp.path().join("workspace");
562        let project_dir = cwd.join(".pi");
563        fs::create_dir_all(agent_dir.join("hooks")).expect("create global hooks");
564        fs::create_dir_all(project_dir.join("hooks")).expect("create project hooks");
565        write(&agent_dir.join("tools/custom.sh"), "#!/bin/sh\necho hi\n");
566
567        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
568        assert!(!report.deprecation_warnings.is_empty());
569        assert!(
570            report
571                .messages()
572                .iter()
573                .any(|line| line.contains("Migration guide: "))
574        );
575    }
576
577    #[test]
578    fn migration_is_idempotent() {
579        let temp = TempDir::new().expect("tempdir");
580        let agent_dir = temp.path().join("agent");
581        let cwd = temp.path().join("workspace");
582        fs::create_dir_all(&agent_dir).expect("create agent dir");
583        fs::create_dir_all(&cwd).expect("create cwd");
584
585        write(
586            &agent_dir.join("oauth.json"),
587            r#"{"anthropic":{"access_token":"a","refresh_token":"r","expires":1}}"#,
588        );
589        write(
590            &agent_dir.join("legacy.jsonl"),
591            &format!("{{\"type\":\"session\",\"cwd\":\"{}\"}}\n", cwd.display()),
592        );
593        write(&agent_dir.join("commands/hello.md"), "# hello");
594        write(&agent_dir.join("tools/fd"), "fd-binary");
595
596        let first = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
597        assert!(!first.migrated_auth_providers.is_empty());
598        assert!(first.migrated_session_files > 0);
599
600        let second = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
601        assert!(second.migrated_auth_providers.is_empty());
602        assert_eq!(second.migrated_session_files, 0);
603        assert!(second.migrated_commands_dirs.is_empty());
604        assert!(second.migrated_tool_binaries.is_empty());
605    }
606
607    #[test]
608    fn empty_layout_is_noop() {
609        let temp = TempDir::new().expect("tempdir");
610        let agent_dir = temp.path().join("agent");
611        let cwd = temp.path().join("workspace");
612        fs::create_dir_all(&cwd).expect("create cwd");
613
614        let report = run_startup_migrations_with_agent_dir(&agent_dir, &cwd);
615        assert!(report.migrated_auth_providers.is_empty());
616        assert_eq!(report.migrated_session_files, 0);
617        assert!(report.migrated_commands_dirs.is_empty());
618        assert!(report.migrated_tool_binaries.is_empty());
619        assert!(report.deprecation_warnings.is_empty());
620        assert!(report.warnings.is_empty());
621    }
622
623    mod proptest_migrations {
624        use crate::migrations::{MigrationReport, session_cwd_from_header};
625        use proptest::prelude::*;
626
627        proptest! {
628            /// Empty `MigrationReport` produces empty messages.
629            #[test]
630            fn empty_report_no_messages(_dummy in 0..1u8) {
631                let report = MigrationReport::default();
632                assert!(report.messages().is_empty());
633            }
634
635            /// Auth provider migration message includes all provider names.
636            #[test]
637            fn messages_include_providers(
638                p1 in "[a-z]{3,8}",
639                p2 in "[a-z]{3,8}"
640            ) {
641                let report = MigrationReport {
642                    migrated_auth_providers: vec![p1.clone(), p2.clone()],
643                    ..Default::default()
644                };
645                let msgs = report.messages();
646                assert_eq!(msgs.len(), 1);
647                assert!(msgs[0].contains(&p1));
648                assert!(msgs[0].contains(&p2));
649            }
650
651            /// Session migration message includes count.
652            #[test]
653            fn messages_include_session_count(count in 1..100usize) {
654                let report = MigrationReport {
655                    migrated_session_files: count,
656                    ..Default::default()
657                };
658                let msgs = report.messages();
659                assert_eq!(msgs.len(), 1);
660                assert!(msgs[0].contains(&count.to_string()));
661            }
662
663            /// Warnings are prefixed with "Warning: ".
664            #[test]
665            fn messages_prefix_warnings(warning in "[a-z ]{5,20}") {
666                let report = MigrationReport {
667                    warnings: vec![warning.clone()],
668                    ..Default::default()
669                };
670                let msgs = report.messages();
671                assert_eq!(msgs.len(), 1);
672                assert!(msgs[0].starts_with("Warning: "));
673                assert!(msgs[0].contains(&warning));
674            }
675
676            /// Deprecation warnings add guide/docs URLs.
677            #[test]
678            fn messages_deprecation_adds_urls(warning in "[a-z ]{5,20}") {
679                let report = MigrationReport {
680                    deprecation_warnings: vec![warning],
681                    ..Default::default()
682                };
683                let msgs = report.messages();
684                // warning + guide URL + docs URL
685                assert_eq!(msgs.len(), 3);
686                assert!(msgs[1].contains("Migration guide:"));
687                assert!(msgs[2].contains("Extensions docs:"));
688            }
689
690            /// `session_cwd_from_header` extracts cwd from valid session header.
691            #[test]
692            fn session_cwd_extraction(cwd in "[/a-z]{3,20}") {
693                let dir = tempfile::tempdir().unwrap();
694                let path = dir.path().join("test.jsonl");
695                let header = serde_json::json!({
696                    "type": "session",
697                    "cwd": cwd,
698                    "id": "test"
699                });
700                std::fs::write(&path, serde_json::to_string(&header).unwrap()).unwrap();
701                assert_eq!(session_cwd_from_header(&path), Some(cwd));
702            }
703
704            /// `session_cwd_from_header` returns None for wrong type.
705            #[test]
706            fn session_cwd_wrong_type(type_val in "[a-z]{3,10}") {
707                prop_assume!(type_val != "session");
708                let dir = tempfile::tempdir().unwrap();
709                let path = dir.path().join("test.jsonl");
710                let header = serde_json::json!({
711                    "type": type_val,
712                    "cwd": "/test"
713                });
714                std::fs::write(&path, serde_json::to_string(&header).unwrap()).unwrap();
715                assert_eq!(session_cwd_from_header(&path), None);
716            }
717
718            /// `session_cwd_from_header` returns None for empty file.
719            #[test]
720            fn session_cwd_empty_file(_dummy in 0..1u8) {
721                let dir = tempfile::tempdir().unwrap();
722                let path = dir.path().join("empty.jsonl");
723                std::fs::write(&path, "").unwrap();
724                assert_eq!(session_cwd_from_header(&path), None);
725            }
726
727            /// `session_cwd_from_header` returns None for invalid JSON.
728            #[test]
729            fn session_cwd_invalid_json(s in "[a-z]{5,20}") {
730                let dir = tempfile::tempdir().unwrap();
731                let path = dir.path().join("bad.jsonl");
732                std::fs::write(&path, &s).unwrap();
733                assert_eq!(session_cwd_from_header(&path), None);
734            }
735
736            /// Message count equals sum of non-empty field contributions.
737            #[test]
738            fn messages_count_additive(
739                n_providers in 0..3usize,
740                sessions in 0..5usize,
741                n_warnings in 0..3usize,
742                n_deprecations in 0..3usize
743            ) {
744                let report = MigrationReport {
745                    migrated_auth_providers: (0..n_providers).map(|i| format!("p{i}")).collect(),
746                    migrated_session_files: sessions,
747                    migrated_commands_dirs: Vec::new(),
748                    migrated_tool_binaries: Vec::new(),
749                    warnings: (0..n_warnings).map(|i| format!("w{i}")).collect(),
750                    deprecation_warnings: (0..n_deprecations).map(|i| format!("d{i}")).collect(),
751                };
752                let msgs = report.messages();
753                let mut expected = 0;
754                if n_providers > 0 { expected += 1; }
755                if sessions > 0 { expected += 1; }
756                expected += n_warnings;
757                expected += n_deprecations;
758                if n_deprecations > 0 { expected += 2; } // guide + docs URLs
759                assert_eq!(msgs.len(), expected);
760            }
761        }
762    }
763}