rust-bucket-cli 0.9.3

Long-horizon agentic coding scaffold for Rust projects
Documentation
// Configuration file parsing and management

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use thiserror::Error;

/// Errors that can occur when working with configuration files
#[derive(Debug, Error)]
pub enum ConfigError {
    /// IO error when reading or writing the config file
    #[error("IO error: {0}")]
    IoError(#[from] std::io::Error),

    /// Error parsing the TOML config file
    #[error("Parse error: {0}")]
    ParseError(#[from] toml::de::Error),

    /// Error serializing the config to TOML
    #[error("Serialize error: {0}")]
    SerializeError(#[from] toml::ser::Error),
}

/// Configuration for rust-bucket
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    /// Version of rust-bucket that generated this config
    pub rust_bucket_version: String,
    /// Test timeout in seconds (default: 120)
    pub test_timeout: u32,
    /// Name of the project
    pub project_name: String,
}

// `Config` intentionally has no `Default`: `project_name` is meaningful only
// relative to a target repository, so it must always be supplied explicitly
// (see `apply::derive_project_name`). A crate-name-derived default would
// silently mislabel every target as the rust-bucket crate itself.

impl Config {
    /// Load configuration from a TOML file
    pub fn load(path: &Path) -> Result<Config, ConfigError> {
        let contents = fs::read_to_string(path)?;
        let config = toml::from_str(&contents)?;
        Ok(config)
    }

    /// Save configuration to a TOML file with a header comment
    pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
        let toml_string = toml::to_string_pretty(self)?;
        let version = env!("CARGO_PKG_VERSION");
        let header = format!(
            "# Generated by rust-bucket v{}. DO NOT EDIT BY HAND.\n\n",
            version
        );
        let contents = header + &toml_string;
        fs::write(path, contents)?;
        Ok(())
    }
}

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

    #[test]
    fn test_save_and_load_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
        let temp_dir = tempfile::tempdir()?;
        let config_path = temp_dir.path().join("rust-bucket.toml");

        // Create a config with custom values
        let original_config = Config {
            rust_bucket_version: "0.1.0".to_string(),
            test_timeout: 300,
            project_name: "test-project".to_string(),
        };

        // Save the config
        original_config.save(&config_path)?;

        // Verify the file contains the header comment
        let file_contents = fs::read_to_string(&config_path)?;
        assert!(file_contents.starts_with("# Generated by rust-bucket v"));
        assert!(file_contents.contains("DO NOT EDIT BY HAND"));

        // Load the config back
        let loaded_config = Config::load(&config_path)?;

        // Verify the values match
        assert_eq!(loaded_config.rust_bucket_version, "0.1.0");
        assert_eq!(loaded_config.test_timeout, 300);
        assert_eq!(loaded_config.project_name, "test-project");
        Ok(())
    }

    #[test]
    fn test_load_nonexistent_file() {
        let path = PathBuf::from("/nonexistent/path/config.toml");
        let result = Config::load(&path);
        assert!(result.is_err());
        assert!(matches!(result.unwrap_err(), ConfigError::IoError(_)));
    }

    #[test]
    fn test_load_invalid_toml() -> Result<(), Box<dyn std::error::Error>> {
        let temp_dir = tempfile::tempdir()?;
        let config_path = temp_dir.path().join("invalid.toml");

        // Write invalid TOML
        fs::write(&config_path, "this is not valid toml { [ }")?;

        let result = Config::load(&config_path);
        assert!(result.is_err());
        assert!(matches!(result.unwrap_err(), ConfigError::ParseError(_)));
        Ok(())
    }

    #[test]
    fn test_save_to_readonly_directory() {
        // This test would require setting up a read-only directory
        // which is platform-dependent and may require elevated privileges.
        // Skipping for simplicity, but the error handling is in place.
    }
}