a3s 0.9.6

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use a3s_boot::{BootError, Result as BootResult};
use a3s_code_core::config::{rewrite_acl_sections, ConfigSection};
use a3s_code_core::CodeConfig;

static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);

pub(crate) fn persist_config_sections(
    path: &Path,
    config: &CodeConfig,
    sections: &[ConfigSection],
) -> BootResult<CodeConfig> {
    let source = match fs::read_to_string(path) {
        Ok(source) => source,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(error) => {
            return Err(BootError::Internal(format!(
                "failed to read {}: {error}",
                path.display()
            )))
        }
    };
    let rendered = rewrite_acl_sections(&source, config, sections)
        .map_err(|error| BootError::BadRequest(error.to_string()))?;
    let verified = CodeConfig::from_acl(&rendered).map_err(|error| {
        BootError::BadRequest(format!("generated config.acl is invalid: {error}"))
    })?;
    write_atomic(path, rendered.as_bytes())?;
    Ok(verified)
}

pub(crate) fn write_atomic(path: &Path, contents: &[u8]) -> BootResult<()> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    fs::create_dir_all(parent).map_err(|error| {
        BootError::Internal(format!(
            "failed to create config directory {}: {error}",
            parent.display()
        ))
    })?;
    let temp_path = temporary_path(path);
    let result = write_and_replace(path, &temp_path, contents);
    if result.is_err() {
        let _ = fs::remove_file(&temp_path);
    }
    result
}

fn write_and_replace(path: &Path, temp_path: &Path, contents: &[u8]) -> BootResult<()> {
    let mut file = OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(temp_path)
        .map_err(|error| {
            BootError::Internal(format!(
                "failed to create temporary config {}: {error}",
                temp_path.display()
            ))
        })?;
    file.write_all(contents).map_err(|error| {
        BootError::Internal(format!(
            "failed to write temporary config {}: {error}",
            temp_path.display()
        ))
    })?;
    file.sync_all().map_err(|error| {
        BootError::Internal(format!(
            "failed to flush temporary config {}: {error}",
            temp_path.display()
        ))
    })?;

    if let Ok(metadata) = fs::metadata(path) {
        fs::set_permissions(temp_path, metadata.permissions()).map_err(|error| {
            BootError::Internal(format!(
                "failed to preserve permissions for {}: {error}",
                path.display()
            ))
        })?;
    }

    fs::rename(temp_path, path).map_err(|error| {
        BootError::Internal(format!(
            "failed to replace config {}: {error}",
            path.display()
        ))
    })?;
    sync_parent_directory(path);
    Ok(())
}

fn sync_parent_directory(path: &Path) {
    let Some(parent) = path.parent() else {
        return;
    };
    if let Ok(directory) = File::open(parent) {
        let _ = directory.sync_all();
    }
}

fn temporary_path(path: &Path) -> PathBuf {
    let file_name = path
        .file_name()
        .and_then(|value| value.to_str())
        .unwrap_or("config.acl");
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or_default();
    let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
    path.with_file_name(format!(
        ".{file_name}.{}.{}.{}.tmp",
        std::process::id(),
        timestamp,
        sequence
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn atomic_write_replaces_content_and_leaves_no_temp_file() {
        let directory = std::env::temp_dir().join(format!(
            "a3s-code-web-config-persistence-{}-{}",
            std::process::id(),
            TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(&directory).expect("temp directory");
        let path = directory.join("config.acl");
        fs::write(&path, "default_model = \"old/model\"\n").expect("initial config");

        write_atomic(&path, b"default_model = \"new/model\"\n").expect("atomic write");

        assert_eq!(
            fs::read_to_string(&path).expect("updated config"),
            "default_model = \"new/model\"\n"
        );
        assert_eq!(
            fs::read_dir(&directory).expect("directory entries").count(),
            1
        );
        fs::remove_dir_all(&directory).expect("cleanup");
    }
}