1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct MigrationReport {
22 pub migrated_auth_providers: Vec<String>,
24 pub migrated_session_files: usize,
26 pub migrated_commands_dirs: Vec<PathBuf>,
28 pub migrated_tool_binaries: Vec<String>,
30 pub deprecation_warnings: Vec<String>,
32 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#[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(msgs.len(), 3);
686 assert!(msgs[1].contains("Migration guide:"));
687 assert!(msgs[2].contains("Extensions docs:"));
688 }
689
690 #[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 #[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 #[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 #[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 #[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; } assert_eq!(msgs.len(), expected);
760 }
761 }
762 }
763}