roboticus-core 0.11.2

Shared types, config parsing, personality system, and error types for the Roboticus agent runtime
Documentation
/// Controls orchestrator autonomy during subagent composition.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CompositionPolicy {
    Autonomous,
    #[default]
    Propose,
    Manual,
}

/// Controls validation rigor when autonomously creating skills.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SkillCreationRigor {
    Generate,
    Validate,
    #[default]
    Full,
}

/// Controls delegation output quality evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OutputValidationPolicy {
    #[default]
    Strict,
    Sample,
    Off,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
    pub name: String,
    pub id: String,
    #[serde(default = "default_workspace")]
    pub workspace: PathBuf,
    #[serde(default = "default_log_level")]
    pub log_level: String,
    #[serde(default = "default_true")]
    pub delegation_enabled: bool,
    #[serde(default = "default_min_decomposition_complexity")]
    pub delegation_min_complexity: f64,
    #[serde(default = "default_min_delegation_utility_margin")]
    pub delegation_min_utility_margin: f64,
    #[serde(default = "default_true")]
    pub specialist_creation_requires_approval: bool,
    #[serde(default = "default_autonomy_max_react_turns")]
    pub autonomy_max_react_turns: usize,
    #[serde(default = "default_autonomy_max_turn_duration_seconds")]
    pub autonomy_max_turn_duration_seconds: u64,
    #[serde(default)]
    pub composition_policy: CompositionPolicy,
    #[serde(default)]
    pub skill_creation_rigor: SkillCreationRigor,
    #[serde(default)]
    pub output_validation_policy: OutputValidationPolicy,
    #[serde(default = "default_output_validation_sample_rate")]
    pub output_validation_sample_rate: f64,
    #[serde(default = "default_max_output_retries")]
    pub max_output_retries: u32,
    #[serde(default = "default_retirement_threshold")]
    pub retirement_success_threshold: f64,
    #[serde(default = "default_retirement_min_delegations")]
    pub retirement_min_delegations: i64,
}

fn default_workspace() -> PathBuf {
    dirs_next().join("workspace")
}

fn default_log_level() -> String {
    "info".into()
}

fn default_min_decomposition_complexity() -> f64 {
    0.35
}

fn default_min_delegation_utility_margin() -> f64 {
    0.15
}

fn default_autonomy_max_react_turns() -> usize {
    10
}

fn default_autonomy_max_turn_duration_seconds() -> u64 {
    90
}

fn default_output_validation_sample_rate() -> f64 {
    0.25
}

fn default_max_output_retries() -> u32 {
    2
}

fn default_retirement_threshold() -> f64 {
    0.3
}

fn default_retirement_min_delegations() -> i64 {
    5
}

impl Default for AgentConfig {
    fn default() -> Self {
        Self {
            name: String::new(),
            id: String::new(),
            workspace: default_workspace(),
            log_level: default_log_level(),
            delegation_enabled: true,
            delegation_min_complexity: default_min_decomposition_complexity(),
            delegation_min_utility_margin: default_min_delegation_utility_margin(),
            specialist_creation_requires_approval: true,
            autonomy_max_react_turns: default_autonomy_max_react_turns(),
            autonomy_max_turn_duration_seconds: default_autonomy_max_turn_duration_seconds(),
            composition_policy: CompositionPolicy::default(),
            skill_creation_rigor: SkillCreationRigor::default(),
            output_validation_policy: OutputValidationPolicy::default(),
            output_validation_sample_rate: default_output_validation_sample_rate(),
            max_output_retries: default_max_output_retries(),
            retirement_success_threshold: default_retirement_threshold(),
            retirement_min_delegations: default_retirement_min_delegations(),
        }
    }
}

fn default_log_dir() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
    PathBuf::from(home).join(".roboticus").join("logs")
}

fn default_log_max_days() -> u32 {
    7
}

fn dirs_next() -> PathBuf {
    let home = home_dir();
    let new_dir = home.join(".roboticus");
    migrate_legacy_data_dir(&home, &new_dir);
    new_dir
}

/// One-shot migration from `~/.ironclad` → `~/.roboticus`.
///
/// Handles:
///  1. Atomic rename (same filesystem) with copy+delete fallback (cross-filesystem).
///  2. Renames `ironclad.toml` → `roboticus.toml` inside the migrated directory.
///  3. Rewrites internal paths (`/.ironclad/` → `/.roboticus/`) in the config TOML.
///  4. Warns (but does not clobber) if both directories already exist.
fn migrate_legacy_data_dir(home: &Path, new_dir: &Path) {
    use std::sync::Once;
    static MIGRATE_ONCE: Once = Once::new();

    MIGRATE_ONCE.call_once(|| {
        let legacy = home.join(".ironclad");

        // Check for sentinel from a previous partial migration (copy succeeded, delete failed).
        let sentinel = new_dir.join(".migration_pending_delete");
        if sentinel.exists()
            && let Ok(source) = std::fs::read_to_string(&sentinel)
        {
            let source_path = Path::new(source.trim());
            if source_path.exists() {
                match std::fs::remove_dir_all(source_path) {
                    Ok(()) => {
                        eprintln!("[roboticus] Completed deferred cleanup of {}", source_path.display());
                        let _ = std::fs::remove_file(&sentinel);
                    }
                    Err(e) => {
                        eprintln!(
                            "[roboticus] Still cannot remove {}: {e} — will retry on next run",
                            source_path.display()
                        );
                    }
                }
            } else {
                // Source no longer exists; sentinel is stale.
                let _ = std::fs::remove_file(&sentinel);
            }
        }

        if !legacy.exists() {
            return;
        }
        if new_dir.exists() {
            eprintln!(
                "[roboticus] Both ~/.ironclad and ~/.roboticus exist; skipping automatic migration. \
                 Merge manually and remove ~/.ironclad to silence this warning."
            );
            return;
        }

        // Attempt rename (fast, atomic on the same filesystem).
        if std::fs::rename(&legacy, new_dir).is_err() {
            // Cross-filesystem: recursive copy then delete.
            if let Err(e) = copy_dir_recursive(&legacy, new_dir) {
                eprintln!("[roboticus] failed to copy ~/.ironclad to ~/.roboticus: {e}");
                return;
            }
            if let Err(e) = std::fs::remove_dir_all(&legacy) {
                eprintln!(
                    "[roboticus] copied ~/.ironclad to ~/.roboticus but could not remove the original: {e}"
                );
                // Write sentinel so the next run re-attempts the delete.
                let _ = std::fs::write(&sentinel, legacy.to_string_lossy().as_ref());
            }
        }
        eprintln!("[roboticus] Migrated data directory from ~/.ironclad to ~/.roboticus");

        // Rename ironclad.toml → roboticus.toml inside the new dir.
        let old_config = new_dir.join("ironclad.toml");
        let new_config = new_dir.join("roboticus.toml");
        if old_config.exists() && !new_config.exists() {
            if let Err(e) = std::fs::rename(&old_config, &new_config) {
                eprintln!("[roboticus] failed to rename ironclad.toml to roboticus.toml: {e}");
            } else {
                eprintln!("[roboticus] Renamed ironclad.toml → roboticus.toml");
            }
        }

        // Rewrite legacy paths in ALL .toml files under the migrated directory.
        rewrite_all_toml_files(new_dir);
    });
}

/// Walk `dir` recursively and rewrite legacy ironclad paths in every `.toml` file found.
pub fn rewrite_all_toml_files(dir: &Path) {
    let walker = match std::fs::read_dir(dir) {
        Ok(w) => w,
        Err(_) => return,
    };
    for entry in walker.flatten() {
        let path = entry.path();
        if path.is_dir() {
            rewrite_all_toml_files(&path);
        } else if path.extension().and_then(|e| e.to_str()) == Some("toml") {
            rewrite_legacy_paths_in_config(&path);
        }
    }
}

/// Rewrite `/.ironclad/` → `/.roboticus/` inside a TOML config file.
/// Handles both Unix forward-slash and Windows backslash path styles,
/// as well as end-of-value patterns where the path ends at a quote boundary.
pub fn rewrite_legacy_paths_in_config(path: &Path) {
    let Ok(content) = std::fs::read_to_string(path) else {
        return;
    };
    let rewritten = content
        // Mid-path patterns (path continues after .ironclad)
        .replace("/.ironclad/", "/.roboticus/")
        .replace("\\.ironclad\\", "\\.roboticus\\")
        .replace("\\\\.ironclad\\\\", "\\\\.roboticus\\\\")
        // End-of-value patterns (path ends at .ironclad with a quote boundary)
        .replace("/.ironclad\"", "/.roboticus\"")
        .replace("/.ironclad'", "/.roboticus'")
        .replace("\\.ironclad\"", "\\.roboticus\"")
        .replace("\\.ironclad'", "\\.roboticus'")
        .replace("\\\\.ironclad\"", "\\\\.roboticus\"")
        .replace("\\\\.ironclad'", "\\\\.roboticus'");
    if rewritten != content {
        if let Err(e) = std::fs::write(path, &rewritten) {
            eprintln!(
                "[roboticus] failed to rewrite legacy paths in {}: {e}",
                path.display()
            );
        } else {
            eprintln!(
                "[roboticus] Rewrote legacy paths in {}",
                path.display()
            );
        }
    }
}

/// Recursively copy a directory tree. Does not follow symlinks.
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
    std::fs::create_dir_all(dst)?;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let ty = entry.file_type()?;
        let dest_path = dst.join(entry.file_name());
        if ty.is_dir() {
            copy_dir_recursive(&entry.path(), &dest_path)?;
        } else if ty.is_file() {
            std::fs::copy(entry.path(), &dest_path)?;
        }
        // Skip symlinks and special files.
    }
    Ok(())
}

/// Returns the user's home directory, checking `HOME` first (Unix / MSYS2 / Git Bash)
/// then `USERPROFILE` (native Windows). Falls back to the platform temp directory.
pub fn home_dir() -> PathBuf {
    std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .map(PathBuf::from)
        .unwrap_or_else(|_| std::env::temp_dir())
}

/// Resolves the configuration file path using a standard precedence chain:
///
/// 1. Explicit path (from `--config` flag or `ROBOTICUS_CONFIG` env var)
/// 2. `~/.roboticus/roboticus.toml` (if it exists)
/// 3. `./roboticus.toml` in the current working directory (if it exists)
/// 4. Legacy fallbacks: `~/.roboticus/ironclad.toml`, `./ironclad.toml`
/// 5. `None` — caller decides the fallback (e.g., built-in defaults or error)
pub fn resolve_config_path(explicit: Option<&str>) -> Option<PathBuf> {
    if let Some(p) = explicit {
        return Some(expand_tilde(Path::new(p)));
    }

    // Legacy env var fallback: IRONCLAD_CONFIG → ROBOTICUS_CONFIG equivalent.
    if let Ok(legacy_env) = std::env::var("IRONCLAD_CONFIG") {
        eprintln!(
            "[roboticus] IRONCLAD_CONFIG is deprecated; use ROBOTICUS_CONFIG instead. \
             Falling back to: {legacy_env}"
        );
        return Some(expand_tilde(Path::new(&legacy_env)));
    }

    // Ensure legacy data dir migration has run.
    let home = home_dir();
    let roboticus_dir = home.join(".roboticus");
    migrate_legacy_data_dir(&home, &roboticus_dir);

    let home_config = roboticus_dir.join("roboticus.toml");
    if home_config.exists() {
        return Some(home_config);
    }
    let cwd_config = PathBuf::from("roboticus.toml");
    if cwd_config.exists() {
        return Some(cwd_config);
    }

    // Legacy fallback: ironclad.toml (user may have explicit references to old name).
    let legacy_home = roboticus_dir.join("ironclad.toml");
    if legacy_home.exists() {
        tracing::info!("Using legacy config file: {}", legacy_home.display());
        return Some(legacy_home);
    }
    let legacy_cwd = PathBuf::from("ironclad.toml");
    if legacy_cwd.exists() {
        tracing::info!("Using legacy config file: ironclad.toml in current directory");
        return Some(legacy_cwd);
    }
    None
}

/// Expands a leading `~` in `path` to the user's home directory; otherwise returns the path unchanged.
fn expand_tilde(path: &Path) -> PathBuf {
    if let Ok(stripped) = path.strip_prefix("~") {
        home_dir().join(stripped)
    } else {
        path.to_path_buf()
    }
}