Skip to main content

oxicode/foundation/
compat_import.rs

1//! One-time legacy compatibility import.
2//!
3//! While the migration is enabled (`OXICODE_FOUNDATION_MIGRATION=1`),
4//! oxicode reads a single legacy profile from a host-provided
5//! compatibility shim, writes a structured migration marker, and
6//! resolves the profile through the same decision function
7//! ([`crate::foundation::profiles::resolve_profile`]) as a normal
8//! Foundation profile.
9//!
10//! Defaults: disabled. The importer never reads from `~/.oxicode/auth.json`
11//! on its own — the user explicitly acknowledges the import via
12//! `oxicode memory migrate-brain` / `oxicode config migrate-foundation`.
13
14use std::path::Path;
15
16use crate::foundation::FoundationError;
17use crate::foundation::profiles::{CompatibilityImport, Profile};
18
19/// `true` when the migration flag is set.
20pub fn migration_enabled() -> bool {
21    matches!(
22        std::env::var("OXICODE_FOUNDATION_MIGRATION").as_deref(),
23        Ok("1") | Ok("true") | Ok("TRUE")
24    )
25}
26
27/// Read the legacy compatibility shim. The shim is a small JSON file
28/// that lives under `~/.oxi/foundation/v1/compatibility.json` and is
29/// produced by the host's compatibility installer. oxicode never
30/// fabricates one.
31pub fn read_compatibility_shim(
32    path: &Path,
33) -> Result<Option<CompatibilityImport>, FoundationError> {
34    if !path.is_file() {
35        return Ok(None);
36    }
37    let raw = std::fs::read_to_string(path)?;
38    if raw.trim().is_empty() {
39        return Ok(None);
40    }
41    let profile: Profile = serde_json::from_str(&raw)?;
42    profile.credential.validate()?;
43    Ok(Some(CompatibilityImport { profile }))
44}
45
46/// Write a migration marker. The marker is a JSON file under the
47/// foundation root that records who/what was migrated. The marker is
48/// for human auditing, not for runtime decisions.
49pub fn write_migration_marker(root: &Path, profile_id: &str) -> Result<(), FoundationError> {
50    let path = root.join("migration.marker.json");
51    let body = serde_json::json!({
52        "profile_id": profile_id,
53        "migrated_at": chrono::Utc::now().to_rfc3339(),
54        "migrated_by": "oxicode",
55    });
56    std::fs::write(
57        &path,
58        serde_json::to_string_pretty(&body).map_err(|e| FoundationError::Parse(e.to_string()))?,
59    )?;
60    Ok(())
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::foundation::profiles::CredentialLocator;
67
68    #[test]
69    fn migration_disabled_by_default() {
70        // The test runner may have the env var set; tolerate that.
71        let original = std::env::var("OXICODE_FOUNDATION_MIGRATION").ok();
72        unsafe {
73            std::env::remove_var("OXICODE_FOUNDATION_MIGRATION");
74        }
75        assert!(!migration_enabled());
76        if let Some(value) = original {
77            unsafe {
78                std::env::set_var("OXICODE_FOUNDATION_MIGRATION", value);
79            }
80        }
81    }
82
83    #[test]
84    fn migrate_marker_roundtrip() {
85        let tmp = tempfile::tempdir().unwrap();
86        write_migration_marker(tmp.path(), "legacy").unwrap();
87        let body = std::fs::read_to_string(tmp.path().join("migration.marker.json")).unwrap();
88        assert!(body.contains("legacy"));
89    }
90
91    #[test]
92    fn shim_returns_none_when_missing() {
93        let tmp = tempfile::tempdir().unwrap();
94        let opt = read_compatibility_shim(&tmp.path().join("missing.json")).unwrap();
95        assert!(opt.is_none());
96    }
97
98    #[test]
99    fn shim_parses_minimal_profile() {
100        let tmp = tempfile::tempdir().unwrap();
101        let path = tmp.path().join("compatibility.json");
102        let raw = r#"{
103            "id": "legacy",
104            "provider": "anthropic",
105            "model": "claude-sonnet",
106            "roles": ["coding.primary"],
107            "credential": { "service": "dev.oxi.foundation", "account": "legacy" }
108        }"#;
109        std::fs::write(&path, raw).unwrap();
110        let import = read_compatibility_shim(&path).unwrap().unwrap();
111        assert_eq!(import.profile.provider, "anthropic");
112        assert_eq!(
113            import.profile.credential,
114            CredentialLocator {
115                service: "dev.oxi.foundation".to_string(),
116                account: "legacy".to_string(),
117            }
118        );
119    }
120}