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 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 #[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 #[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 #[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 #[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 #[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 assert_eq!(msgs.len(), 3);
839 assert!(msgs[1].contains("Migration guide:"));
840 assert!(msgs[2].contains("Extensions docs:"));
841 }
842
843 #[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 #[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 #[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 #[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 #[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; } assert_eq!(msgs.len(), expected);
913 }
914 }
915 }
916}