opencode-ralph-loop-cli 0.1.0

Scaffolder CLI for OpenCode Ralph Loop plugin — one command setup
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;

use crate::cli::{InitArgs, OutputFormat};
use crate::error::CliError;
use crate::manifest::{Manifest, ManifestFile};
use crate::output::{Action, FileEntry, Report};
use crate::templates::{
    DEFAULT_PLUGIN_VERSION, RenderContext, canonical_templates, render_template,
    template_to_output_path,
};

pub fn run(args: &InitArgs, output: &OutputFormat) -> Result<(), CliError> {
    let start = Instant::now();

    let target = resolve_target(&args.path)?;
    let opencode_dir = target.join(".opencode");
    let plugin_version = args
        .plugin_version
        .clone()
        .unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string());

    let ctx = RenderContext::with_version(&plugin_version);

    // --- Phase 1: planning (no write I/O) ---
    let mut planned: Vec<(String, Vec<u8>, Action)> = Vec::new();
    let mut conflict_paths: Vec<String> = Vec::new();

    for &template_path in canonical_templates() {
        if args.no_package_json && template_path == "package.json.template" {
            continue;
        }
        if args.no_gitignore && template_path == ".gitignore.template" {
            continue;
        }

        let output_path = template_to_output_path(template_path);
        let dest = opencode_dir.join(output_path);

        let rendered = render_template(template_path, &ctx)
            .ok_or_else(|| CliError::Generic(format!("template not found: {template_path}")))?;

        let new_hash = crate::hash::sha256_hex(&rendered);

        let action = if dest.exists() {
            let existing = fs::read(&dest)
                .map_err(|e| CliError::io(dest.to_string_lossy().into_owned(), e))?;
            let existing_hash = crate::hash::sha256_hex(&existing);

            if existing_hash == new_hash {
                Action::Skipped
            } else if args.force {
                Action::Updated
            } else {
                conflict_paths.push(output_path.to_string());
                Action::Skipped
            }
        } else {
            Action::Created
        };

        planned.push((output_path.to_string(), rendered, action));
    }

    // --- Reject conflicts before writing anything ---
    if !conflict_paths.is_empty() {
        for p in &conflict_paths {
            eprintln!("CONFLICT {p}");
        }
        return Err(CliError::Conflict {
            path: conflict_paths[0].clone(),
        });
    }

    // --- Phase 2: atomic write (skip on dry-run) ---
    if !args.dry_run {
        for d in &[opencode_dir.join("plugins"), opencode_dir.join("commands")] {
            fs::create_dir_all(d).map_err(|e| CliError::io(d.to_string_lossy().into_owned(), e))?;
        }

        for (out_path, rendered, action) in &planned {
            if *action == Action::Skipped {
                continue;
            }
            let dest = opencode_dir.join(out_path);
            crate::fs_atomic::write_atomic(&dest, rendered)?;
        }
    }

    // --- Phase 3: build report ---
    let mut report = Report::new("init", &plugin_version);
    for (out_path, rendered, action) in &planned {
        let sha256 = crate::hash::sha256_hex(rendered);
        let size_bytes = rendered.len() as u64;
        report.files.push(FileEntry {
            path: out_path.clone(),
            action: action.clone(),
            sha256,
            expected_sha256: None,
            size_bytes,
        });
    }

    // --- Phase 4: save manifest ---
    if !args.dry_run && !args.no_manifest {
        let mut manifest = Manifest::new(&plugin_version);
        for entry in &report.files {
            manifest.add_file(ManifestFile::new(
                &entry.path,
                &entry.sha256,
                entry.size_bytes,
                true,
                entry.action.as_str().to_lowercase(),
            ));
        }
        crate::manifest::save(&opencode_dir, &manifest)?;
    }

    report.duration_ms = start.elapsed().as_millis() as u64;
    report.finalize();

    emit_report(&report, output);

    if !args.dry_run && args.install_hint && matches!(output, OutputFormat::Text) {
        crate::output::text::print_install_hint(&opencode_dir);
    }

    Ok(())
}

fn resolve_target(path: &Path) -> Result<PathBuf, CliError> {
    let resolved = if path.is_absolute() {
        path.to_path_buf()
    } else {
        std::env::current_dir()
            .map_err(|e| CliError::io("current directory", e))?
            .join(path)
    };

    if !resolved.exists() {
        return Err(CliError::Io {
            path: resolved.to_string_lossy().to_string(),
            source: std::io::Error::new(std::io::ErrorKind::NotFound, "directory does not exist"),
        });
    }

    let canonical = resolved
        .canonicalize()
        .map_err(|e| CliError::io(resolved.to_string_lossy().into_owned(), e))?;

    let has_parent = path
        .components()
        .any(|c| c == std::path::Component::ParentDir);

    if has_parent {
        let cwd = std::env::current_dir().map_err(|e| CliError::io("current directory", e))?;
        let cwd_canonical = cwd
            .canonicalize()
            .map_err(|e| CliError::io("current directory", e))?;
        if !canonical.starts_with(&cwd_canonical) {
            return Err(CliError::Usage(
                "path traversal detected: path points outside the working directory".to_string(),
            ));
        }
    }

    Ok(canonical)
}

fn emit_report(report: &Report, format: &OutputFormat) {
    match format {
        OutputFormat::Text => crate::output::text::print_report(report),
        OutputFormat::Json => crate::output::json::print_report(report),
        OutputFormat::Ndjson => crate::output::ndjson::print_report(report),
        OutputFormat::Quiet => {}
    }
}