Skip to content

Implement ProjectEntry and LegacyConfig models for ~/.claude.json #96

@doomspork

Description

@doomspork

Context

LegacyConfig represents ~/.claude.json — the global config with projects map, preferences, customApiKeyResponses, and global MCP servers. ProjectEntry is a single project within that map.

Important: The path field on ProjectEntry is NOT in the JSON — it comes from the key in the projects HashMap. The all_projects() method must populate it from the map key.

Ported from: Fig/Sources/Models/ProjectEntry.swift, Fig/Sources/Models/LegacyConfig.swift

What to implement

File paths

  • fig-core/src/models/project_entry.rs
  • fig-core/src/models/legacy_config.rs

Rust struct definitions

// project_entry.rs
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ProjectEntry {
    /// Not serialized in JSON — populated from the map key in LegacyConfig.projects
    #[serde(skip)]
    pub path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "allowedTools")]
    pub allowed_tools: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "hasTrustDialogAccepted")]
    pub has_trust_dialog_accepted: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub history: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "mcpServers")]
    pub mcp_servers: Option<HashMap<String, MCPServer>>,
    #[serde(flatten)]
    pub additional_properties: HashMap<String, Value>,
}

impl ProjectEntry {
    pub fn name(&self) -> Option<&str> {
        self.path.as_ref()
            .and_then(|p| std::path::Path::new(p).file_name()?.to_str())
    }
    pub fn has_mcp_servers(&self) -> bool {
        self.mcp_servers.as_ref().map(|s| !s.is_empty()).unwrap_or(false)
    }
    pub fn mcp_server_count(&self) -> usize {
        self.mcp_servers.as_ref().map(|s| s.len()).unwrap_or(0)
    }
}

// legacy_config.rs
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct LegacyConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub projects: Option<HashMap<String, ProjectEntry>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "customApiKeyResponses")]
    pub custom_api_key_responses: Option<HashMap<String, Value>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub preferences: Option<HashMap<String, Value>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "mcpServers")]
    pub mcp_servers: Option<HashMap<String, MCPServer>>,
    #[serde(flatten)]
    pub additional_properties: HashMap<String, Value>,
}

impl LegacyConfig {
    /// Returns all project paths from the projects map.
    pub fn project_paths(&self) -> Vec<String> {
        self.projects.as_ref()
            .map(|p| p.keys().cloned().collect())
            .unwrap_or_default()
    }

    /// Returns all projects with their path populated from map keys.
    pub fn all_projects(&self) -> Vec<ProjectEntry> {
        self.projects.as_ref()
            .map(|p| p.iter().map(|(path, entry)| {
                let mut e = entry.clone();
                e.path = Some(path.clone());
                e
            }).collect())
            .unwrap_or_default()
    }

    pub fn global_server_names(&self) -> Vec<String> {
        self.mcp_servers.as_ref()
            .map(|s| s.keys().cloned().collect())
            .unwrap_or_default()
    }

    pub fn project(&self, path: &str) -> Option<ProjectEntry> {
        self.projects.as_ref()?.get(path).map(|entry| {
            let mut e = entry.clone();
            e.path = Some(path.to_string());
            e
        })
    }
}

Acceptance criteria

  • Parses real ~/.claude.json content
  • all_projects() populates path from map keys
  • Round-trip preserves customApiKeyResponses and preferences as opaque Value
  • path field is #[serde(skip)] so it's not serialized to JSON

Test requirements

  • test_legacy_config_round_trip
  • test_project_entry_name_extraction — name derived from path
  • test_all_projects_populates_paths
  • test_project_entry_serde_skip_path — path not in JSON output
  • test_legacy_config_global_servers

Dependencies

Requires: #91, #95

Blocks

#99, #102

Metadata

Metadata

Assignees

No one assigned

    Labels

    epic:config-parsingConfig file discovery, parsing, and data modelsepic:rust-migrationRust + Iced migration infrastructurephase:1Phase 1 — Read-only explorerpriority:highMust-have for MVPtype:migrationDirect port of existing Swift functionality

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions