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";
#[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>,
}
#[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))
}
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)
}
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)
}
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;
}
}
}
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))
}
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)
}
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)
}
pub fn is_management_org_id(id: &str) -> bool {
id.starts_with("org_")
}
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,
}
}
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());
Ok(prefer_management_org(linked, preferred))
}
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")
);
}
}