sylphx-cli 0.2.6

Sylphx Platform CLI — dogfoods the Rust Management SDK
//! Operator context: preferred org + linked project for the working directory.
//!
//! Stored alongside credentials:
//!   ~/.config/sylphx/context.json          — global preferred org
//!   ./.sylphx/project.json                 — directory-linked project (link/unlink)
//!
//! Resolution for `--org-id` / project arguments:
//!   explicit CLI flag > linked project file > global context > none

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};

use crate::credentials;

const GLOBAL_FILE: &str = "context.json";
const LOCAL_DIR: &str = ".sylphx";
const LOCAL_FILE: &str = "project.json";

/// Global operator preferences.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GlobalContext {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub preferred_org_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub preferred_org_slug: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub preferred_org_name: Option<String>,
}

/// Directory-linked project binding (created by `sylphx link`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LinkedProject {
    pub project_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project_slug: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project_name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub org_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub org_slug: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub environment: Option<String>,
}

fn global_path() -> Result<PathBuf> {
    Ok(credentials::config_dir()?.join(GLOBAL_FILE))
}

/// Load global context if present.
pub fn load_global() -> Result<GlobalContext> {
    let path = global_path()?;
    if !path.exists() {
        return Ok(GlobalContext::default());
    }
    let raw = fs::read_to_string(&path)
        .with_context(|| format!("read context {}", path.display()))?;
    let parsed: GlobalContext = serde_json::from_str(&raw)
        .with_context(|| format!("parse context {}", path.display()))?;
    Ok(parsed)
}

/// Persist global context.
pub fn save_global(ctx: &GlobalContext) -> Result<PathBuf> {
    let dir = credentials::config_dir()?;
    fs::create_dir_all(&dir)?;
    let path = dir.join(GLOBAL_FILE);
    let body = serde_json::to_string_pretty(ctx)?;
    write_private(&path, body.as_bytes())?;
    Ok(path)
}

/// Clear preferred org.
pub fn clear_global() -> Result<bool> {
    let path = global_path()?;
    if path.exists() {
        fs::remove_file(&path)?;
        return Ok(true);
    }
    Ok(false)
}

fn find_local_project(start: &Path) -> Option<PathBuf> {
    let mut cur = start.to_path_buf();
    loop {
        let candidate = cur.join(LOCAL_DIR).join(LOCAL_FILE);
        if candidate.is_file() {
            return Some(candidate);
        }
        if !cur.pop() {
            return None;
        }
    }
}

/// Load linked project walking parents from cwd.
pub fn load_linked_project() -> Result<Option<LinkedProject>> {
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let Some(path) = find_local_project(&cwd) else {
        return Ok(None);
    };
    let raw = fs::read_to_string(&path)
        .with_context(|| format!("read linked project {}", path.display()))?;
    let parsed: LinkedProject = serde_json::from_str(&raw)
        .with_context(|| format!("parse linked project {}", path.display()))?;
    if parsed.project_id.trim().is_empty() {
        return Ok(None);
    }
    Ok(Some(parsed))
}

/// Write `.sylphx/project.json` in cwd.
pub fn save_linked_project(link: &LinkedProject) -> Result<PathBuf> {
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let dir = cwd.join(LOCAL_DIR);
    fs::create_dir_all(&dir)?;
    let path = dir.join(LOCAL_FILE);
    let body = serde_json::to_string_pretty(link)?;
    write_private(&path, body.as_bytes())?;
    Ok(path)
}

/// Remove local link in cwd (not parents).
pub fn clear_linked_project() -> Result<bool> {
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let path = cwd.join(LOCAL_DIR).join(LOCAL_FILE);
    if path.exists() {
        fs::remove_file(&path)?;
        return Ok(true);
    }
    Ok(false)
}

/// Resolve org id: CLI > linked project > global preferred.
pub fn resolve_org_id(cli_org: Option<&str>) -> Result<Option<String>> {
    if let Some(o) = cli_org.map(str::trim).filter(|s| !s.is_empty()) {
        return Ok(Some(o.to_string()));
    }
    if let Some(link) = load_linked_project()? {
        if let Some(o) = link.org_id.filter(|s| !s.trim().is_empty()) {
            return Ok(Some(o));
        }
    }
    let g = load_global()?;
    Ok(g.preferred_org_id.filter(|s| !s.trim().is_empty()))
}

/// Resolve project id: CLI > linked project.
pub fn resolve_project_id(cli_project: Option<&str>) -> Result<String> {
    if let Some(p) = cli_project.map(str::trim).filter(|s| !s.is_empty()) {
        return Ok(p.to_string());
    }
    if let Some(link) = load_linked_project()? {
        if !link.project_id.trim().is_empty() {
            return Ok(link.project_id);
        }
    }
    Err(anyhow!(
        "no project specified.\n\
         \n\
         Fix (pick one):\n\
           • pass the project id/slug as an argument\n\
           • sylphx link --project <id> --org-id <org>\n\
           • cd into a linked directory (.sylphx/project.json)"
    ))
}

fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
    use std::io::Write;
    let mut f = fs::File::create(path).with_context(|| format!("create {}", path.display()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = f.set_permissions(fs::Permissions::from_mode(0o600));
    }
    f.write_all(bytes)?;
    f.sync_all()?;
    Ok(())
}