use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
const CONFIG_FILE: &str = "config.json";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct UserConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jan_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec_root: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jan_dir_source_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jan_dir_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub computer_id: Option<String>,
}
pub fn config_dir() -> PathBuf {
if let Ok(p) = std::env::var("JAN_CONFIG_DIR") {
let p = p.trim();
if !p.is_empty() {
return PathBuf::from(p);
}
}
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("jan-cli")
}
pub fn config_path() -> PathBuf {
config_dir().join(CONFIG_FILE)
}
pub fn load_user_config() -> Result<UserConfig> {
let path = config_path();
if !path.is_file() {
return Ok(UserConfig::default());
}
let text = fs::read_to_string(&path)
.with_context(|| format!("read user config {}", path.display()))?;
let cfg: UserConfig = serde_json::from_str(&text)
.with_context(|| format!("parse user config {}", path.display()))?;
Ok(cfg)
}
pub fn save_user_config(cfg: &UserConfig) -> Result<()> {
let dir = config_dir();
fs::create_dir_all(&dir).with_context(|| format!("create config dir {}", dir.display()))?;
let path = config_path();
let text = serde_json::to_string_pretty(cfg).context("serialize user config")?;
fs::write(&path, format!("{text}\n")).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
pub fn clear_user_config() -> Result<()> {
let path = config_path();
if path.is_file() {
fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
}
Ok(())
}
pub fn entry_candidates(explicit_root: Option<&str>) -> Vec<String> {
let mut out = Vec::new();
if let Some(r) = explicit_root {
let r = r.trim();
if !r.is_empty() {
out.push(r.to_string());
}
}
for name in ["scripts.spec.yaml", "jan.spec.yaml", "jan.yaml"] {
if !out.iter().any(|x| x == name) {
out.push(name.to_string());
}
}
out
}
pub fn detect_entry_file(dir: &Path, explicit_root: Option<&str>) -> Result<String> {
if !dir.is_dir() {
bail!("not a directory: {}", dir.display());
}
let candidates = entry_candidates(explicit_root);
for name in &candidates {
let path = dir.join(name);
if path.is_file() {
return Ok(name.clone());
}
}
bail!(
"no jan entry YAML in {} (tried: {})",
dir.display(),
candidates.join(", ")
)
}
pub fn set_preferred_jan_dir(dir: &Path, explicit_root: Option<&str>) -> Result<UserConfig> {
set_preferred_jan_dir_remote(dir, explicit_root, None, None)
}
pub fn set_preferred_jan_dir_remote(
dir: &Path,
explicit_root: Option<&str>,
source_url: Option<&str>,
source_sha256: Option<&str>,
) -> Result<UserConfig> {
let abs = if dir.is_absolute() {
dir.to_path_buf()
} else {
std::env::current_dir().context("current_dir")?.join(dir)
};
let abs = abs
.canonicalize()
.with_context(|| format!("canonicalize {}", abs.display()))?;
let root = detect_entry_file(&abs, explicit_root)?;
let cfg = UserConfig {
jan_dir: Some(abs.to_string_lossy().into_owned()),
spec_root: Some(root),
jan_dir_source_url: source_url.map(|s| s.to_string()),
jan_dir_sha256: source_sha256.map(|s| s.to_ascii_lowercase()),
computer_id: load_user_config()?.computer_id,
};
save_user_config(&cfg)?;
Ok(cfg)
}
fn normalize_computer_id(id: &str) -> Result<String> {
let id = id.trim();
if id.is_empty() {
bail!("computer id must not be empty");
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
bail!(
"computer id `{id}` must contain only ASCII letters, digits, `_`, or `-`"
);
}
Ok(id.to_string())
}
pub fn set_computer_id(id: &str) -> Result<UserConfig> {
let id = normalize_computer_id(id)?;
let mut cfg = load_user_config()?;
cfg.computer_id = Some(id);
save_user_config(&cfg)?;
Ok(cfg)
}
pub fn clear_computer_id() -> Result<UserConfig> {
let mut cfg = load_user_config()?;
cfg.computer_id = None;
save_user_config(&cfg)?;
Ok(cfg)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
#[test]
fn detect_entry_prefers_scripts_spec() {
let _g = LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("jan.spec.yaml"), "commands: {}\n").unwrap();
fs::write(tmp.path().join("scripts.spec.yaml"), "commands: {}\n").unwrap();
assert_eq!(
detect_entry_file(tmp.path(), None).unwrap(),
"scripts.spec.yaml"
);
}
#[test]
fn detect_entry_honors_explicit_root() {
let _g = LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("jan.spec.yaml"), "commands: {}\n").unwrap();
fs::write(tmp.path().join("custom.yaml"), "commands: {}\n").unwrap();
assert_eq!(
detect_entry_file(tmp.path(), Some("custom.yaml")).unwrap(),
"custom.yaml"
);
}
}