use std::path::Path;
use super::clients::{CompiledEntry, Format};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Created,
Updated,
}
impl Action {
pub fn as_str(self) -> &'static str {
match self {
Action::Created => "created",
Action::Updated => "updated",
}
}
}
pub struct Rendered {
pub contents: String,
pub action: Action,
}
#[derive(Debug)]
pub struct WriteError {
pub message: String,
pub manual_snippet: Option<String>,
}
impl WriteError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
manual_snippet: None,
}
}
fn with_snippet(message: impl Into<String>, snippet: impl Into<String>) -> Self {
Self {
message: message.into(),
manual_snippet: Some(snippet.into()),
}
}
}
impl std::fmt::Display for WriteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for WriteError {}
pub fn render(path: &Path, format: Format, entry: &CompiledEntry) -> Result<Rendered, WriteError> {
let existing = read_existing(path)?;
let action = if existing.is_some() {
Action::Updated
} else {
Action::Created
};
let existing_str = existing.as_deref().unwrap_or("");
let contents = match format {
Format::Json => render_json(existing_str, entry)?,
Format::Toml => render_toml(existing_str, entry)?,
Format::Yaml => render_yaml(existing_str, entry)?,
};
Ok(Rendered { contents, action })
}
pub fn write(path: &Path, format: Format, entry: &CompiledEntry) -> Result<Action, WriteError> {
let rendered = render(path, format, entry)?;
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.map_err(|e| WriteError::new(format!("failed to create {}: {e}", parent.display())))?;
}
std::fs::write(path, &rendered.contents)
.map_err(|e| WriteError::new(format!("failed to write {}: {e}", path.display())))?;
Ok(rendered.action)
}
fn read_existing(path: &Path) -> Result<Option<String>, WriteError> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(WriteError::new(format!(
"failed to read {}: {e}",
path.display()
))),
}
}
fn render_json(existing: &str, entry: &CompiledEntry) -> Result<String, WriteError> {
let mut root: serde_json::Value = if existing.trim().is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(existing).map_err(|e| {
WriteError::with_snippet(
format!(
"existing config is not valid JSON ({e}); not modifying it. Merge this in by hand:"
),
manual_json_snippet(entry),
)
})?
};
let serde_json::Value::Object(root_map) = &mut root else {
return Err(WriteError::with_snippet(
"existing config is not a JSON object; not modifying it. Merge this in by hand:",
manual_json_snippet(entry),
));
};
let top = root_map
.entry(entry.top_key.clone())
.or_insert_with(|| serde_json::json!({}));
let serde_json::Value::Object(top_map) = top else {
return Err(WriteError::with_snippet(
format!(
"existing `{}` is not an object; not modifying it. Merge this in by hand:",
entry.top_key
),
manual_json_snippet(entry),
));
};
top_map.insert(entry.name.clone(), entry.value.clone());
let mut out = serde_json::to_string_pretty(&root)
.map_err(|e| WriteError::new(format!("failed to serialize JSON: {e}")))?;
out.push('\n');
Ok(out)
}
fn manual_json_snippet(entry: &CompiledEntry) -> String {
let snippet = serde_json::json!({
entry.top_key.clone(): { entry.name.clone(): entry.value.clone() }
});
serde_json::to_string_pretty(&snippet).unwrap_or_default()
}
fn render_toml(existing: &str, entry: &CompiledEntry) -> Result<String, WriteError> {
use toml_edit::{DocumentMut, Item, Table};
let mut doc: DocumentMut = if existing.trim().is_empty() {
DocumentMut::new()
} else {
existing.parse::<DocumentMut>().map_err(|e| {
WriteError::with_snippet(
format!("existing config.toml does not parse ({e}); not modifying it. Merge by hand:"),
manual_toml_snippet(entry),
)
})?
};
let parts: Vec<&str> = entry.top_key.split('.').collect();
let mut leaf = Table::new();
let obj = entry
.value
.as_object()
.ok_or_else(|| WriteError::new("internal: codex compiled value must be an object"))?;
for (k, v) in obj {
leaf.insert(k, json_to_toml_value(v)?);
}
let mut current = doc.as_table_mut();
let last = parts.len() - 1;
for part in &parts[..last] {
let sub = current.entry(part).or_insert_with(|| {
let mut t = Table::new();
t.set_implicit(true);
Item::Table(t)
});
current = sub.as_table_mut().ok_or_else(|| {
WriteError::with_snippet(
format!("existing `{part}` in config.toml is not a table; not modifying it. Merge by hand:"),
manual_toml_snippet(entry),
)
})?;
}
current.insert(parts[last], Item::Table(leaf));
Ok(doc.to_string())
}
fn json_to_toml_value(v: &serde_json::Value) -> Result<toml_edit::Item, WriteError> {
use toml_edit::{Array, Item, Value, value};
match v {
serde_json::Value::String(s) => Ok(value(s.as_str())),
serde_json::Value::Bool(b) => Ok(value(*b)),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(value(i))
} else if let Some(f) = n.as_f64() {
Ok(value(f))
} else {
Err(WriteError::new("unsupported TOML number"))
}
}
serde_json::Value::Array(arr) => {
let mut a = Array::new();
for item in arr {
match item {
serde_json::Value::String(s) => a.push(s.as_str()),
serde_json::Value::Bool(b) => a.push(*b),
other => {
return Err(WriteError::new(format!(
"unsupported TOML array element: {other}"
)));
}
}
}
Ok(Item::Value(Value::Array(a)))
}
other => Err(WriteError::new(format!("unsupported TOML value: {other}"))),
}
}
fn manual_toml_snippet(entry: &CompiledEntry) -> String {
use toml_edit::{DocumentMut, Item, Table};
let mut doc = DocumentMut::new();
let parts: Vec<&str> = entry.top_key.split('.').collect();
let mut leaf = Table::new();
if let Some(obj) = entry.value.as_object() {
for (k, v) in obj {
if let Ok(item) = json_to_toml_value(v) {
leaf.insert(k, item);
}
}
}
let mut current = doc.as_table_mut();
let last = parts.len() - 1;
for part in &parts[..last] {
let sub = current.entry(part).or_insert_with(|| {
let mut t = Table::new();
t.set_implicit(true);
Item::Table(t)
});
match sub.as_table_mut() {
Some(t) => current = t,
None => return doc.to_string(),
}
}
current.insert(parts[last], Item::Table(leaf));
doc.to_string()
}
fn render_yaml(existing: &str, entry: &CompiledEntry) -> Result<String, WriteError> {
let mut root: serde_yaml::Value = if existing.trim().is_empty() {
serde_yaml::Value::Mapping(serde_yaml::Mapping::new())
} else {
serde_yaml::from_str(existing).map_err(|e| {
WriteError::with_snippet(
format!("existing config.yaml does not parse ({e}); not modifying it. Merge by hand:"),
manual_yaml_snippet(entry),
)
})?
};
let serde_yaml::Value::Mapping(root_map) = &mut root else {
return Err(WriteError::with_snippet(
"existing config.yaml is not a mapping; not modifying it. Merge by hand:",
manual_yaml_snippet(entry),
));
};
let top_key = serde_yaml::Value::String(entry.top_key.clone());
let top = root_map
.entry(top_key)
.or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
let serde_yaml::Value::Mapping(top_map) = top else {
return Err(WriteError::with_snippet(
format!(
"existing `{}` is not a mapping; not modifying it. Merge by hand:",
entry.top_key
),
manual_yaml_snippet(entry),
));
};
let value_yaml: serde_yaml::Value = serde_yaml::to_value(&entry.value)
.map_err(|e| WriteError::new(format!("failed to convert value to YAML: {e}")))?;
top_map.insert(serde_yaml::Value::String(entry.name.clone()), value_yaml);
serde_yaml::to_string(&root)
.map_err(|e| WriteError::new(format!("failed to serialize YAML: {e}")))
}
fn manual_yaml_snippet(entry: &CompiledEntry) -> String {
let mut top = serde_yaml::Mapping::new();
let value_yaml =
serde_yaml::to_value(&entry.value).unwrap_or(serde_yaml::Value::Null);
top.insert(serde_yaml::Value::String(entry.name.clone()), value_yaml);
let mut root = serde_yaml::Mapping::new();
root.insert(
serde_yaml::Value::String(entry.top_key.clone()),
serde_yaml::Value::Mapping(top),
);
serde_yaml::to_string(&serde_yaml::Value::Mapping(root)).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::connect::clients::{ServerConfig, find_client};
fn remote() -> ServerConfig {
ServerConfig::remote("http://127.0.0.1:3456/mcp", "lific_sk-live-K")
}
fn tmp() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"lific-connect-writer-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn json_creates_file_when_absent() {
let dir = tmp();
let path = dir.join("opencode.json");
let entry = find_client("opencode").unwrap().compile(&remote());
let action = write(&path, Format::Json, &entry).unwrap();
assert_eq!(action, Action::Created);
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.ends_with('\n'), "must end with a trailing newline");
let v: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(v["mcp"]["lific"]["type"], "remote");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn json_preserves_sibling_servers_and_unrelated_keys() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("opencode.json");
std::fs::write(
&path,
serde_json::to_string_pretty(&serde_json::json!({
"theme": "dark",
"mcp": {
"other": { "type": "remote", "url": "http://other" }
}
}))
.unwrap(),
)
.unwrap();
let entry = find_client("opencode").unwrap().compile(&remote());
let action = write(&path, Format::Json, &entry).unwrap();
assert_eq!(action, Action::Updated);
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(v["theme"], "dark");
assert_eq!(v["mcp"]["other"]["url"], "http://other");
assert_eq!(v["mcp"]["lific"]["url"], "http://127.0.0.1:3456/mcp");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn json_replaces_existing_lific_entry_not_duplicates() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("opencode.json");
std::fs::write(
&path,
serde_json::to_string_pretty(&serde_json::json!({
"mcp": { "lific": { "type": "remote", "url": "http://stale" } }
}))
.unwrap(),
)
.unwrap();
let entry = find_client("opencode").unwrap().compile(&remote());
write(&path, Format::Json, &entry).unwrap();
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(v["mcp"]["lific"]["url"], "http://127.0.0.1:3456/mcp");
assert_eq!(v["mcp"].as_object().unwrap().len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn json_refuses_to_touch_unparseable_jsonc_and_returns_snippet() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("opencode.json");
let original = "{\n // my config\n \"mcp\": {}\n}\n";
std::fs::write(&path, original).unwrap();
let entry = find_client("opencode").unwrap().compile(&remote());
let err = write(&path, Format::Json, &entry).unwrap_err();
assert!(err.manual_snippet.is_some(), "must hand back a snippet");
let snippet = err.manual_snippet.unwrap();
assert!(snippet.contains("lific"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn toml_sets_only_our_table_and_preserves_comments() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
let original = "# Codex config\nmodel = \"gpt-5\"\n\n[mcp_servers.other]\nurl = \"http://other\"\n";
std::fs::write(&path, original).unwrap();
let entry = find_client("codex").unwrap().compile(&remote());
let action = write(&path, Format::Toml, &entry).unwrap();
assert_eq!(action, Action::Updated);
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("# Codex config"), "comment must survive: {written}");
assert!(written.contains("model = \"gpt-5\""));
assert!(written.contains("[mcp_servers.other]"));
let doc: toml_edit::DocumentMut = written.parse().unwrap();
assert_eq!(
doc["mcp_servers"]["lific"]["url"].as_str(),
Some("http://127.0.0.1:3456/mcp")
);
assert_eq!(
doc["mcp_servers"]["lific"]["bearer_token_env_var"].as_str(),
Some("LIFIC_API_KEY")
);
assert!(!written.contains("lific_sk-live-K"), "must not inline the key");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn toml_creates_fresh_when_absent() {
let dir = tmp();
let path = dir.join("config.toml");
let entry = find_client("codex").unwrap().compile(&remote());
let action = write(&path, Format::Toml, &entry).unwrap();
assert_eq!(action, Action::Created);
let doc: toml_edit::DocumentMut =
std::fs::read_to_string(&path).unwrap().parse().unwrap();
assert_eq!(
doc["mcp_servers"]["lific"]["bearer_token_env_var"].as_str(),
Some("LIFIC_API_KEY")
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn toml_refuses_unparseable_and_returns_snippet() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
let original = "this is = = not valid toml [[[\n";
std::fs::write(&path, original).unwrap();
let entry = find_client("codex").unwrap().compile(&remote());
let err = write(&path, Format::Toml, &entry).unwrap_err();
assert!(err.manual_snippet.is_some());
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn yaml_sets_extensions_lific_and_preserves_other_extensions() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.yaml");
std::fs::write(
&path,
"GOOSE_MODEL: gpt-5\nextensions:\n other:\n type: stdio\n cmd: foo\n",
)
.unwrap();
let entry = find_client("goose").unwrap().compile(&remote());
let action = write(&path, Format::Yaml, &entry).unwrap();
assert_eq!(action, Action::Updated);
let v: serde_yaml::Value =
serde_yaml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(v["GOOSE_MODEL"].as_str(), Some("gpt-5"));
assert_eq!(v["extensions"]["other"]["cmd"].as_str(), Some("foo"));
assert_eq!(
v["extensions"]["lific"]["type"].as_str(),
Some("streamable_http")
);
assert_eq!(v["extensions"]["lific"]["uri"].as_str(), Some("http://127.0.0.1:3456/mcp"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn render_does_not_write_to_disk() {
let dir = tmp();
let path = dir.join("opencode.json");
let entry = find_client("opencode").unwrap().compile(&remote());
let rendered = render(&path, Format::Json, &entry).unwrap();
assert_eq!(rendered.action, Action::Created);
assert!(rendered.contents.contains("lific"));
assert!(!path.exists());
}
}