use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use super::{ClientError, SetupResult};
pub(super) fn read_or_empty(path: &Path) -> Result<String, ClientError> {
match fs::read_to_string(path) {
Ok(source) => Ok(source),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(ClientError::message(format!(
"could not read {}: {error}",
path.display()
))),
}
}
pub(super) fn read_environment_value(
path: &Path,
name: &str,
) -> Result<Option<String>, ClientError> {
let source = read_or_empty(path)?;
let prefix = format!("export {name}=");
Ok(source.lines().find_map(|line| {
let raw = line.strip_prefix(&prefix)?.trim();
Some(
raw.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
.unwrap_or(raw)
.replace("'\\''", "'"),
)
}))
}
pub(super) fn write_if_changed(
path: &Path,
before: &str,
after: &str,
) -> Result<SetupResult, ClientError> {
if before == after {
return Ok(unchanged(path.to_path_buf()));
}
let parent = path.parent().ok_or_else(|| {
ClientError::message(format!("{} has no parent directory", path.display()))
})?;
fs::create_dir_all(parent)?;
let backup = path.exists().then(|| backup_file(path)).transpose()?;
atomic_write(path, after.as_bytes())?;
Ok(SetupResult {
path: path.to_path_buf(),
backup,
changed: true,
})
}
pub(super) const fn unchanged(path: PathBuf) -> SetupResult {
SetupResult {
path,
backup: None,
changed: false,
}
}
pub(super) fn backup_file(path: &Path) -> Result<PathBuf, ClientError> {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| ClientError::message("config file name is not valid UTF-8"))?;
let backup = path.with_file_name(format!("{file_name}.link-assistant-router.{stamp}.bak"));
fs::copy(path, &backup)?;
Ok(backup)
}
pub(super) fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), ClientError> {
crate::durable_file::atomic_write_owner_only(path, contents).map_err(Into::into)
}
pub(super) fn write_claude_marker(path: &Path, base_url: &str) -> Result<(), ClientError> {
let rendered = format!(
"{}\n",
serde_json::to_string_pretty(&json!({
"anthropic_base_url": base_url
}))?
);
if read_or_empty(path)? != rendered {
let parent = path
.parent()
.ok_or_else(|| ClientError::message("missing marker parent"))?;
fs::create_dir_all(parent)?;
atomic_write(path, rendered.as_bytes())?;
}
Ok(())
}
pub(super) fn read_claude_marker(path: &Path) -> Result<Option<String>, ClientError> {
let source = read_or_empty(path)?;
if source.trim().is_empty() {
return Ok(None);
}
let marker: Value = serde_json::from_str(&source)?;
Ok(marker
.get("anthropic_base_url")
.and_then(Value::as_str)
.map(str::to_string))
}
pub(super) fn write_codex_marker(
path: &Path,
previous_provider: Option<&str>,
) -> Result<(), ClientError> {
let rendered = format!(
"{}\n",
serde_json::to_string_pretty(&json!({
"previous_model_provider": previous_provider
}))?
);
let parent = path
.parent()
.ok_or_else(|| ClientError::message("missing marker parent"))?;
fs::create_dir_all(parent)?;
atomic_write(path, rendered.as_bytes())
}
pub(super) fn read_codex_marker(path: &Path) -> Result<Option<String>, ClientError> {
let source = read_or_empty(path)?;
if source.trim().is_empty() {
return Ok(None);
}
let marker: Value = serde_json::from_str(&source)?;
Ok(marker
.get("previous_model_provider")
.and_then(Value::as_str)
.map(str::to_string))
}