use std::fs;
use std::io::Write as _;
use std::path::PathBuf;
use serde::Deserialize;
use crate::core::error::{
SsError, ERR_CONFIG_WRITE_FAILED, ERR_INVALID_CONFIG, ERR_STATE_WRITE_FAILED,
};
pub const DEFAULT_API_BASE: &str = "https://saferskills.ai";
pub const DEFAULT_MIN_SCORE: u8 = 90;
pub fn saferskills_dir() -> PathBuf {
if let Ok(dir) = std::env::var("SAFERSKILLS_DIR") {
if !dir.is_empty() {
return PathBuf::from(dir);
}
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".saferskills")
}
pub fn config_path() -> PathBuf {
saferskills_dir().join("config.toml")
}
pub fn installs_path() -> PathBuf {
saferskills_dir().join("installs.json")
}
pub fn scan_cache_path() -> PathBuf {
saferskills_dir().join("scan_cache.json")
}
pub fn contract_home(p: &std::path::Path) -> String {
let s = p.to_string_lossy().into_owned();
if let Some(home) = dirs::home_dir() {
let h = home.to_string_lossy();
if let Some(rest) = s.strip_prefix(h.as_ref()) {
return format!("~{rest}");
}
}
s
}
pub fn cache_dir() -> PathBuf {
saferskills_dir().join("cache")
}
pub fn bin_dir() -> PathBuf {
saferskills_dir().join("bin")
}
pub fn ensure_dir() -> Result<(), SsError> {
let dir = saferskills_dir();
fs::create_dir_all(&dir).map_err(|e| {
SsError::new(
ERR_STATE_WRITE_FAILED,
format!("Failed to create {}: {e}", dir.display()),
)
.with_exit_code(if e.kind() == std::io::ErrorKind::PermissionDenied {
4
} else {
1
})
})
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Config {
pub api_url: Option<String>,
pub min_score: Option<u8>,
pub telemetry: Option<bool>,
pub audited: Option<bool>,
}
impl Config {
pub fn load() -> Result<Self, SsError> {
let path = config_path();
let raw = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => {
return Err(SsError::new(
ERR_INVALID_CONFIG,
format!("Failed to read {}: {e}", path.display()),
))
}
};
toml::from_str(&raw).map_err(|e| {
SsError::new(ERR_INVALID_CONFIG, format!("Invalid config.toml: {e}"))
.with_suggestion("Fix the syntax, or delete the file to restore defaults.")
})
}
pub fn api_base(&self, cli_override: Option<&str>) -> String {
if let Some(v) = cli_override {
return v.trim_end_matches('/').to_string();
}
if let Ok(v) = std::env::var("SAFERSKILLS_API_URL") {
if !v.is_empty() {
return v.trim_end_matches('/').to_string();
}
}
self.api_url
.as_deref()
.filter(|v| !v.is_empty())
.unwrap_or(DEFAULT_API_BASE)
.trim_end_matches('/')
.to_string()
}
pub fn min_score(&self) -> u8 {
if let Ok(v) = std::env::var("SAFERSKILLS_MIN_SCORE") {
if let Ok(n) = v.trim().parse::<u32>() {
return n.min(100) as u8;
}
}
self.min_score.unwrap_or(DEFAULT_MIN_SCORE).min(100)
}
}
pub fn default_config_toml() -> &'static str {
"# SaferSkills CLI configuration (~/.saferskills/config.toml)\n\
# Every key is optional; uncomment to override the default.\n\
\n\
# API origin the CLI reads from. Default: https://saferskills.ai\n\
# api_url = \"https://saferskills.ai\"\n\
\n\
# Minimum aggregate score (0–100) that installs without a confirm. Default: 90\n\
# Below this an install warns + asks; red-tier (<40) requires typing the name.\n\
# Also overridable with SAFERSKILLS_MIN_SCORE.\n\
# min_score = 90\n\
\n\
# Anonymous usage analytics (PostHog). Asked once on first run; stored here.\n\
# Set false to opt out — also disabled by SAFERSKILLS_NO_TELEMETRY /\n\
# DO_NOT_TRACK / CI, or forced on with SAFERSKILLS_TELEMETRY=1.\n\
# (Anonymous install counts are reported automatically; the same opt-out\n\
# envs above suppress them too.)\n\
# telemetry = false\n\
\n\
# Set true once the one-time first-launch security audit has been offered.\n\
# Managed by the CLI; you should not need to edit this.\n\
# audited = false\n"
}
pub fn set_telemetry(value: bool) -> Result<(), SsError> {
let path = config_path();
let base = fs::read_to_string(&path).unwrap_or_else(|_| default_config_toml().to_string());
let mut doc = base.parse::<toml_edit::DocumentMut>().unwrap_or_default();
doc["telemetry"] = toml_edit::value(value);
ensure_dir()?;
atomic_write(&path, doc.to_string().as_bytes())
}
pub fn set_audited(value: bool) -> Result<(), SsError> {
let path = config_path();
let base = fs::read_to_string(&path).unwrap_or_else(|_| default_config_toml().to_string());
let mut doc = base.parse::<toml_edit::DocumentMut>().unwrap_or_default();
doc["audited"] = toml_edit::value(value);
ensure_dir()?;
atomic_write(&path, doc.to_string().as_bytes())
}
pub fn write_default_config_if_missing() -> Result<(), SsError> {
let path = config_path();
if path.exists() {
return Ok(());
}
ensure_dir()?;
atomic_write(&path, default_config_toml().as_bytes())
.map_err(|e| SsError::new(ERR_CONFIG_WRITE_FAILED, e.message))
}
pub fn atomic_write(path: &std::path::Path, bytes: &[u8]) -> Result<(), SsError> {
let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
fs::create_dir_all(parent).map_err(|e| write_err(path, e))?;
let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| write_err(path, e))?;
tmp.write_all(bytes).map_err(|e| write_err(path, e))?;
tmp.as_file().sync_all().map_err(|e| write_err(path, e))?;
tmp.persist(path).map_err(|e| write_err(path, e.error))?;
Ok(())
}
fn write_err(path: &std::path::Path, e: std::io::Error) -> SsError {
let exit = if e.kind() == std::io::ErrorKind::PermissionDenied {
4
} else {
1
};
SsError::new(
ERR_STATE_WRITE_FAILED,
format!("Failed to write {}: {e}", path.display()),
)
.with_exit_code(exit)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saferskills_dir_honors_env_override() {
std::env::set_var("SAFERSKILLS_DIR", "/tmp/ss-test-dir");
assert_eq!(saferskills_dir(), PathBuf::from("/tmp/ss-test-dir"));
std::env::remove_var("SAFERSKILLS_DIR");
}
#[test]
fn config_defaults_resolve_api_base() {
let cfg = Config::default();
assert_eq!(cfg.api_base(None), DEFAULT_API_BASE);
assert_eq!(cfg.api_base(Some("https://x.test/")), "https://x.test");
}
#[test]
fn parse_config_toml() {
let cfg: Config =
toml::from_str("api_url = \"https://staging.test\"\nmin_score = 75\n").unwrap();
assert_eq!(cfg.api_url.as_deref(), Some("https://staging.test"));
assert_eq!(cfg.min_score, Some(75));
assert!(cfg.telemetry.is_none()); }
#[test]
fn min_score_precedence_env_over_config_over_default() {
let default_cfg = Config::default();
assert_eq!(default_cfg.min_score(), DEFAULT_MIN_SCORE);
let configured = Config {
min_score: Some(50),
..Config::default()
};
assert_eq!(configured.min_score(), 50);
std::env::set_var("SAFERSKILLS_MIN_SCORE", "70");
assert_eq!(configured.min_score(), 70); assert_eq!(default_cfg.min_score(), 70);
std::env::set_var("SAFERSKILLS_MIN_SCORE", "150");
assert_eq!(default_cfg.min_score(), 100);
std::env::set_var("SAFERSKILLS_MIN_SCORE", "high");
assert_eq!(configured.min_score(), 50);
std::env::remove_var("SAFERSKILLS_MIN_SCORE");
}
#[test]
fn atomic_write_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("f.txt");
atomic_write(&path, b"hello").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
atomic_write(&path, b"world").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "world");
}
#[test]
fn default_template_is_all_comments() {
for line in default_config_toml()
.lines()
.filter(|l| !l.trim().is_empty())
{
assert!(
line.trim_start().starts_with('#'),
"active key in template: {line}"
);
}
}
}