runx-cli 0.6.19

Cargo-installed launcher for the runx governed agent workflow CLI.
Documentation
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};

use super::{
    CODEX_RULE_END, CODEX_RULE_RUNX_ON_PATH, CODEX_RULE_RUNX_RESUME_ON_PATH, CODEX_RULE_START,
    ExportError, GeneratedFile, Target, display_path,
};

pub(super) fn write_files(files: &[GeneratedFile]) -> Result<(), ExportError> {
    for file in files {
        let parent = file.path.parent().ok_or_else(|| ExportError::Io {
            context: format!("resolving parent for {}", display_path(&file.path)),
            source: std::io::Error::other("path has no parent"),
        })?;
        fs::create_dir_all(parent).map_err(|source| ExportError::Io {
            context: format!("creating {}", display_path(parent)),
            source,
        })?;
        fs::write(&file.path, &file.contents).map_err(|source| ExportError::Io {
            context: format!("writing {}", display_path(&file.path)),
            source,
        })?;
    }
    Ok(())
}

pub(super) fn prune_managed_files(
    target: Target,
    skill_dir: &Path,
    files: &[GeneratedFile],
) -> Result<Vec<String>, ExportError> {
    let wanted = files
        .iter()
        .map(|file| file.path.clone())
        .collect::<BTreeSet<_>>();
    let entries = match fs::read_dir(skill_dir) {
        Ok(entries) => entries,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(source) => {
            return Err(ExportError::Io {
                context: format!("reading {}", display_path(skill_dir)),
                source,
            });
        }
    };
    let mut pruned = Vec::new();
    for entry in entries {
        let entry = entry.map_err(|source| ExportError::Io {
            context: format!("reading {}", display_path(skill_dir)),
            source,
        })?;
        let skill_file = entry.path().join("SKILL.md");
        if wanted.contains(&skill_file) || !skill_file.exists() {
            continue;
        }
        let contents = read_to_string(&skill_file)?;
        if !contents.contains(target.marker()) {
            continue;
        }
        fs::remove_file(&skill_file).map_err(|source| ExportError::Io {
            context: format!("removing {}", display_path(&skill_file)),
            source,
        })?;
        let _ignored = fs::remove_dir(entry.path());
        pruned.push(display_path(&skill_file));
    }
    pruned.sort();
    Ok(pruned)
}

pub(super) fn merge_codex_rules(path: &Path, runx_bin: &Path) -> Result<PathBuf, ExportError> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|source| ExportError::Io {
            context: format!("creating {}", display_path(parent)),
            source,
        })?;
    }
    let existing = match fs::read_to_string(path) {
        Ok(contents) => contents,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(source) => {
            return Err(ExportError::Io {
                context: format!("reading {}", display_path(path)),
                source,
            });
        }
    };
    let block = format!(
        "{CODEX_RULE_START}\n{CODEX_RULE_RUNX_ON_PATH}\n{CODEX_RULE_RUNX_RESUME_ON_PATH}\n{}\n{CODEX_RULE_END}\n",
        codex_rule_for_binary(runx_bin)
    );
    let contents = replace_or_append_block(&existing, &block);
    fs::write(path, contents).map_err(|source| ExportError::Io {
        context: format!("writing {}", display_path(path)),
        source,
    })?;
    Ok(path.to_path_buf())
}

fn codex_rule_for_binary(runx_bin: &Path) -> String {
    let path =
        serde_json::to_string(&display_path(runx_bin)).unwrap_or_else(|_| "\"runx\"".to_owned());
    format!(
        "prefix_rule(pattern = [{path}, \"skill\"], decision = \"allow\", justification = \"runx skill invocations are trusted\")\nprefix_rule(pattern = [{path}, \"resume\"], decision = \"allow\", justification = \"runx resume invocations are trusted\")"
    )
}

fn replace_or_append_block(existing: &str, block: &str) -> String {
    if let Some(start) = existing.find(CODEX_RULE_START)
        && let Some(relative_end) = existing[start..].find(CODEX_RULE_END)
    {
        let end = start + relative_end + CODEX_RULE_END.len();
        let mut output = String::new();
        output.push_str(&existing[..start]);
        output.push_str(block);
        let suffix = existing[end..].trim_start_matches(['\r', '\n']);
        output.push_str(suffix);
        return ensure_trailing_newline(output);
    }
    let mut output = ensure_trailing_newline(existing.to_owned());
    if !output.is_empty() && !output.ends_with("\n\n") {
        output.push('\n');
    }
    output.push_str(block);
    output
}

fn read_to_string(path: &Path) -> Result<String, ExportError> {
    fs::read_to_string(path).map_err(|source| ExportError::Io {
        context: format!("reading {}", display_path(path)),
        source,
    })
}

fn ensure_trailing_newline(mut value: String) -> String {
    if !value.is_empty() && !value.ends_with('\n') {
        value.push('\n');
    }
    value
}