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