sylphx-cli 0.2.10

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)
}

/// True when the id looks like a Management-native `org_*` token.
pub fn is_management_org_id(id: &str) -> bool {
    id.starts_with("org_")
}

/// Prefer Management-native `org_*` ids over legacy UUID org ids.
///
/// Linked projects historically stored UUID `orgId` values that live Management
/// rejects for project-scoped routes (`project_not_found`). When both a linked
/// org and a global preferred org are present, choose the `org_*` form.
fn prefer_management_org(primary: Option<String>, fallback: Option<String>) -> Option<String> {
    match (primary, fallback) {
        (Some(a), Some(_b)) if is_management_org_id(&a) => Some(a),
        (Some(a), Some(b)) if is_management_org_id(&b) && !is_management_org_id(&a) => Some(b),
        (Some(a), _) => Some(a),
        (None, Some(b)) => Some(b),
        (None, None) => None,
    }
}

/// Resolve org id: CLI > (linked project ∪ global preferred, preferring `org_*`).
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()));
    }
    let linked = load_linked_project()?
        .and_then(|link| link.org_id)
        .filter(|s| !s.trim().is_empty());
    let preferred = load_global()?
        .preferred_org_id
        .filter(|s| !s.trim().is_empty());
    // Linked still wins when it is already a Management-native `org_*` id;
    // otherwise prefer preferred_org when it is `org_*` over a legacy UUID link.
    Ok(prefer_management_org(linked, preferred))
}

/// 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(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn prefer_management_org_prefers_org_star_over_uuid_link() {
        let linked = Some("d6bf3b81-6dd6-42f5-8a03-47ae9d7638b6".into());
        let preferred = Some("org_6pqwxr2vep8btrm0t7nteqce5p".into());
        assert_eq!(
            prefer_management_org(linked, preferred).as_deref(),
            Some("org_6pqwxr2vep8btrm0t7nteqce5p")
        );
    }

    #[test]
    fn prefer_management_org_keeps_linked_when_already_native() {
        let linked = Some("org_linked".into());
        let preferred = Some("org_preferred".into());
        assert_eq!(
            prefer_management_org(linked, preferred).as_deref(),
            Some("org_linked")
        );
    }

    #[test]
    fn prefer_management_org_falls_back_to_linked_uuid_when_no_preferred() {
        let linked = Some("d6bf3b81-6dd6-42f5-8a03-47ae9d7638b6".into());
        assert_eq!(
            prefer_management_org(linked, None).as_deref(),
            Some("d6bf3b81-6dd6-42f5-8a03-47ae9d7638b6")
        );
    }
}