Skip to main content

opencode_ralph_loop_cli/commands/
check.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::Instant;
4
5use crate::cli::{CheckArgs, OutputFormat};
6use crate::error::CliError;
7use crate::output::{Action, FileEntry, Report};
8use crate::templates::{
9    RenderContext, canonical_templates, render_template, template_to_output_path,
10};
11
12pub fn run(args: &CheckArgs, output: &OutputFormat) -> Result<(), CliError> {
13    let start = Instant::now();
14
15    let target = resolve_target(&args.path)?;
16    let opencode_dir = target.join(".opencode");
17
18    let manifest = crate::manifest::load(&opencode_dir)?;
19    let plugin_version = manifest.plugin_version.clone();
20    let ctx = RenderContext::with_version(&plugin_version);
21
22    let mut report = Report::new("check", &plugin_version);
23    let mut has_drift = false;
24
25    for &template_path in canonical_templates() {
26        let output_path = template_to_output_path(template_path);
27
28        if output_path.starts_with("node_modules") {
29            continue;
30        }
31
32        let dest = opencode_dir.join(output_path);
33
34        let rendered = render_template(template_path, &ctx)
35            .ok_or_else(|| CliError::Generic(format!("template not found: {template_path}")))?;
36        let expected_hash = crate::hash::sha256_hex(&rendered);
37        let size_bytes = rendered.len() as u64;
38
39        let (action, current_sha, expected_sha) = if !dest.exists() {
40            has_drift = true;
41            (Action::Missing, String::new(), Some(expected_hash.clone()))
42        } else {
43            let existing = fs::read(&dest)
44                .map_err(|e| CliError::io(dest.to_string_lossy().into_owned(), e))?;
45            let current_hash = crate::hash::sha256_hex(&existing);
46
47            if current_hash != expected_hash {
48                has_drift = true;
49                (Action::Modified, current_hash, Some(expected_hash.clone()))
50            } else {
51                (Action::Skipped, current_hash, None)
52            }
53        };
54
55        report.files.push(FileEntry {
56            path: output_path.to_string(),
57            action,
58            sha256: current_sha,
59            expected_sha256: expected_sha,
60            size_bytes,
61        });
62
63        if args.strict && has_drift {
64            report.duration_ms = start.elapsed().as_millis() as u64;
65            report.finalize();
66            emit_report(&report, output);
67            return Err(CliError::DriftStrict);
68        }
69    }
70
71    report.duration_ms = start.elapsed().as_millis() as u64;
72    report.finalize();
73    emit_report(&report, output);
74
75    if has_drift && !args.exit_zero {
76        return Err(CliError::Generic("drift detected".to_string()));
77    }
78
79    Ok(())
80}
81
82fn resolve_target(path: &Path) -> Result<PathBuf, CliError> {
83    let resolved = if path.is_absolute() {
84        path.to_path_buf()
85    } else {
86        std::env::current_dir()
87            .map_err(|e| CliError::io("current directory", e))?
88            .join(path)
89    };
90
91    if !resolved.exists() {
92        return Err(CliError::Io {
93            path: resolved.to_string_lossy().to_string(),
94            source: std::io::Error::new(std::io::ErrorKind::NotFound, "directory does not exist"),
95        });
96    }
97
98    let canonical = resolved
99        .canonicalize()
100        .map_err(|e| CliError::io(resolved.to_string_lossy().into_owned(), e))?;
101
102    let has_parent = path
103        .components()
104        .any(|c| c == std::path::Component::ParentDir);
105
106    if has_parent {
107        let cwd = std::env::current_dir().map_err(|e| CliError::io("current directory", e))?;
108        let cwd_canonical = cwd
109            .canonicalize()
110            .map_err(|e| CliError::io("current directory", e))?;
111        if !canonical.starts_with(&cwd_canonical) {
112            return Err(CliError::Usage(
113                "path traversal detected: path points outside the working directory".to_string(),
114            ));
115        }
116    }
117
118    Ok(canonical)
119}
120
121fn emit_report(report: &Report, format: &OutputFormat) {
122    match format {
123        OutputFormat::Text => crate::output::text::print_report(report),
124        OutputFormat::Json => crate::output::json::print_report(report),
125        OutputFormat::Ndjson => crate::output::ndjson::print_report(report),
126        OutputFormat::Quiet => {}
127    }
128}