use std::io::Write;
use std::path::PathBuf;
use std::time::Instant;
use anyhow::{Context, Result, bail};
use serde::Serialize;
use crate::atomic::{AtomicWriteOptions, atomic_write};
use crate::cli::{GlobalArgs, SetArgs};
use crate::ndjson_types::WriteOutput;
use crate::output::NdjsonWriter;
#[derive(Debug, Serialize)]
struct SetResult {
r#type: &'static str,
path: String,
config_path: String,
key_path: String,
old_value: Option<String>,
new_value: String,
format: &'static str,
comments_preserved: bool,
elapsed_ms: u64,
}
pub fn cmd_set(
args: &SetArgs,
global: &GlobalArgs,
writer: &mut NdjsonWriter<impl Write>,
) -> Result<()> {
let start = Instant::now();
let workspace = global.resolve_workspace()?;
let validated = crate::path_safety::validate_path(&args.path, &workspace)?;
if !validated.exists() {
bail!("file does not exist: {}", validated.display());
}
let original = std::fs::read_to_string(&validated)
.with_context(|| format!("cannot read {}", validated.display()))?;
let (new_content, old_value, format) = match validated.extension().and_then(|s| s.to_str()) {
Some("toml") => toml_set(&original, &args.key_path, &args.value)?,
Some("json") => json_set(&original, &args.key_path, &args.value)?,
other => bail!(
"unsupported format for `set` (extension: {:?}); supported: toml, json",
other
),
};
let opts = AtomicWriteOptions {
backup: args.backup,
syntax_check: false,
retention: 5,
preserve_timestamps: args.preserve_timestamps,
backup_output_dir: None,
strategy: None,
strict_atomic: false,
};
let result = atomic_write(&validated, new_content.as_bytes(), &opts, &workspace)?;
let output = SetResult {
r#type: "set",
path: validated.display().to_string(),
config_path: validated.display().to_string(),
key_path: args.key_path.clone(),
old_value,
new_value: args.value.clone(),
format,
comments_preserved: true,
elapsed_ms: start.elapsed().as_millis() as u64,
};
let _ = result; writer.write_event(&output)?;
let _ = WriteOutput {
r#type: "write",
status: "success",
path: validated.display().to_string(),
bytes_written: new_content.len() as u64,
checksum: blake3::hash(new_content.as_bytes()).to_hex().to_string(),
checksum_before: None,
backup_path: None,
elapsed_ms: start.elapsed().as_millis() as u64,
platform: result.platform,
};
Ok(())
}
fn toml_set(
original: &str,
key_path: &str,
value: &str,
) -> Result<(String, Option<String>, &'static str)> {
let mut doc: toml_edit::DocumentMut = original
.parse()
.with_context(|| format!("invalid TOML in source: {original}"))?;
let old_value = doc
.get(key_path)
.map(|item| item.to_string().trim().to_owned());
set_toml_path(&mut doc, key_path, value);
let new_content = doc.to_string();
Ok((new_content, old_value, "toml"))
}
fn set_toml_path(doc: &mut toml_edit::DocumentMut, key_path: &str, value: &str) {
let segments: Vec<&str> = key_path.split('.').collect();
if segments.is_empty() {
return;
}
if segments.len() == 1 {
doc[segments[0]] = parse_toml_value(value);
return;
}
let mut current = doc.as_item_mut();
for (i, seg) in segments.iter().enumerate() {
let is_last = i == segments.len() - 1;
if is_last {
if let Some(table) = current.as_table_mut() {
table[seg] = parse_toml_value(value);
} else {
doc[*seg] = parse_toml_value(value);
}
return;
}
let next_seg = segments[i + 1];
if current.as_table_mut().is_none() {
return;
}
let table = current.as_table_mut().unwrap();
if !table.contains_key(seg) {
table.insert(seg, toml_edit::Item::Table(toml_edit::Table::new()));
}
current = table.get_mut(seg).unwrap();
let _ = next_seg;
}
}
fn parse_toml_value(s: &str) -> toml_edit::Item {
if s == "true" {
return toml_edit::value(true);
}
if s == "false" {
return toml_edit::value(false);
}
if let Ok(n) = s.parse::<i64>() {
return toml_edit::value(n);
}
if let Ok(n) = s.parse::<f64>() {
return toml_edit::value(n);
}
toml_edit::value(s)
}
fn json_set(
original: &str,
key_path: &str,
value: &str,
) -> Result<(String, Option<String>, &'static str)> {
let mut value_json: serde_json::Value =
serde_json::from_str(original).with_context(|| "invalid JSON in source")?;
let old_value = value_json
.pointer(&json_pointer(key_path))
.map(|v| v.to_string());
apply_json_pointer(&mut value_json, &json_pointer(key_path), value);
let new_content = serde_json::to_string_pretty(&value_json)?;
Ok((new_content, old_value, "json"))
}
fn json_pointer(path: &str) -> String {
format!("/{}", path.replace('.', "/"))
}
fn apply_json_pointer(root: &mut serde_json::Value, pointer: &str, value: &str) {
use serde_json::Value;
let segments: Vec<&str> = pointer
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
if segments.is_empty() {
*root = parse_json_value(value);
return;
}
let mut current = root;
for (i, seg) in segments.iter().enumerate() {
if i == segments.len() - 1 {
match current {
Value::Object(map) => {
map.insert((*seg).to_owned(), parse_json_value(value));
}
Value::Array(arr) => {
if let Ok(idx) = seg.parse::<usize>() {
if idx < arr.len() {
arr[idx] = parse_json_value(value);
} else {
arr.push(parse_json_value(value));
}
}
}
_ => {
*current = parse_json_value(value);
}
}
return;
}
let _is_last_next = i + 1 == segments.len() - 1;
match current {
Value::Object(map) => {
if !map.contains_key(*seg) {
map.insert((*seg).to_owned(), Value::Object(serde_json::Map::new()));
}
current = map.get_mut(*seg).unwrap();
}
Value::Array(arr) => {
if let Ok(idx) = seg.parse::<usize>() {
while arr.len() <= idx {
arr.push(Value::Null);
}
current = &mut arr[idx];
} else {
return; }
}
_ => return, }
}
}
fn parse_json_value(s: &str) -> serde_json::Value {
if let Ok(v) = serde_json::from_str(s) {
return v;
}
if s == "true" {
return serde_json::Value::Bool(true);
}
if s == "false" {
return serde_json::Value::Bool(false);
}
if let Ok(n) = s.parse::<i64>() {
return serde_json::Value::Number(n.into());
}
if let Ok(n) = s.parse::<f64>() {
if let Some(num) = serde_json::Number::from_f64(n) {
return serde_json::Value::Number(num);
}
}
serde_json::Value::String(s.to_owned())
}
#[allow(dead_code)]
fn _path_buf_marker() -> PathBuf {
PathBuf::new()
}