Skip to main content

runx_cli/
policy.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::fmt;
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::process::ExitCode;
8
9use runx_contracts::{
10    OperationalPolicy, OperationalPolicyError, OperationalPolicyReadback,
11    OperationalPolicyValidationFinding, project_operational_policy_readback,
12};
13use serde::Serialize;
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
16#[serde(rename_all = "lowercase")]
17pub enum PolicyAction {
18    Inspect,
19    Lint,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct PolicyPlan {
24    pub action: PolicyAction,
25    pub path: PathBuf,
26    pub json: bool,
27}
28
29pub fn run_native_policy(plan: PolicyPlan) -> ExitCode {
30    let cwd = match env::current_dir() {
31        Ok(cwd) => cwd,
32        Err(error) => {
33            let _ignored = crate::cli_io::write_stderr_code(&format!(
34                "runx: failed to resolve cwd: {error}\n"
35            ));
36            return ExitCode::from(1);
37        }
38    };
39    match run_policy_command(&plan, &crate::cli_io::env_map(), &cwd) {
40        Ok(output) => crate::cli_io::write_stdout_code(&output.stdout, output.exit_code),
41        Err(error) => {
42            let _ignored = crate::cli_io::write_stderr_code(&format!("runx: {error}\n"));
43            ExitCode::from(error.exit_code())
44        }
45    }
46}
47
48pub fn run_policy_command(
49    plan: &PolicyPlan,
50    env: &BTreeMap<String, String>,
51    cwd: &Path,
52) -> Result<PolicyCliOutput, PolicyCliError> {
53    let resolved_path = resolve_policy_path(&plan.path, env, cwd);
54    let raw = fs::read_to_string(&resolved_path)
55        .map_err(|error| PolicyCliError::Read(resolved_path.clone(), error))?;
56    let policy = serde_json::from_str::<OperationalPolicy>(&raw)
57        .map_err(|error| PolicyCliError::Parse(resolved_path.clone(), error))?;
58    let readback = project_operational_policy_readback(&policy)?;
59    let findings = readback.findings.clone();
60    let result = PolicyCommandResult {
61        action: plan.action,
62        status: if readback.valid { "success" } else { "failure" },
63        path: display_policy_path(&resolved_path, env, cwd),
64        policy: readback,
65        findings,
66    };
67    let stdout = if plan.json {
68        serde_json::to_string_pretty(&result)
69            .map(|json| format!("{json}\n"))
70            .map_err(PolicyCliError::Serialize)?
71    } else {
72        render_policy_result(&result)
73    };
74    let exit_code = if result.status == "success" { 0 } else { 1 };
75    Ok(PolicyCliOutput { stdout, exit_code })
76}
77
78#[derive(Debug)]
79pub struct PolicyCliOutput {
80    pub stdout: String,
81    pub exit_code: u8,
82}
83
84#[derive(Debug)]
85pub enum PolicyCliError {
86    Read(PathBuf, io::Error),
87    Parse(PathBuf, serde_json::Error),
88    Contract(OperationalPolicyError),
89    Serialize(serde_json::Error),
90}
91
92impl PolicyCliError {
93    fn exit_code(&self) -> u8 {
94        match self {
95            Self::Read(_, _) | Self::Parse(_, _) | Self::Contract(_) | Self::Serialize(_) => 1,
96        }
97    }
98}
99
100impl fmt::Display for PolicyCliError {
101    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102        match self {
103            Self::Read(path, error) => {
104                write!(
105                    formatter,
106                    "failed to read policy {}: {error}",
107                    path.display()
108                )
109            }
110            Self::Parse(path, error) => {
111                write!(formatter, "invalid JSON policy {}: {error}", path.display())
112            }
113            Self::Contract(error) => write!(formatter, "{error}"),
114            Self::Serialize(error) => write!(formatter, "failed to serialize policy: {error}"),
115        }
116    }
117}
118
119impl std::error::Error for PolicyCliError {}
120
121impl From<OperationalPolicyError> for PolicyCliError {
122    fn from(error: OperationalPolicyError) -> Self {
123        Self::Contract(error)
124    }
125}
126
127#[derive(Serialize)]
128struct PolicyCommandResult {
129    action: PolicyAction,
130    status: &'static str,
131    path: String,
132    policy: OperationalPolicyReadback,
133    findings: Vec<OperationalPolicyValidationFinding>,
134}
135
136impl PolicyCommandResult {
137    fn findings(&self) -> &[OperationalPolicyValidationFinding] {
138        &self.findings
139    }
140}
141
142fn render_policy_result(result: &PolicyCommandResult) -> String {
143    let mut lines = vec![
144        String::new(),
145        format!(
146            "  {}  policy {}  {}",
147            status_icon(result.status),
148            policy_action_name(result.action),
149            result.status
150        ),
151    ];
152    lines.extend(render_key_value_rows(&[
153        ("path", result.path.clone()),
154        ("policy", result.policy.policy_id.clone()),
155        ("schema", result.policy.schema_version.to_string()),
156        ("sources", result.policy.sources.len().to_string()),
157        ("targets", result.policy.targets.len().to_string()),
158        ("runners", result.policy.runners.len().to_string()),
159        ("findings", result.findings().len().to_string()),
160    ]));
161    push_sources(&mut lines, result);
162    push_targets(&mut lines, result);
163    push_findings(&mut lines, result.findings());
164    lines.push(String::new());
165    format!("{}\n", lines.join("\n"))
166}
167
168fn push_sources(lines: &mut Vec<String>, result: &PolicyCommandResult) {
169    if result.policy.sources.is_empty() {
170        return;
171    }
172    lines.push(String::new());
173    lines.push("  sources".to_owned());
174    for source in &result.policy.sources {
175        lines.push(format!(
176            "  - {}: {}; locators={}; thread={}; actions={}",
177            source.source_id,
178            source.provider,
179            source.locator_count,
180            source_thread_label(source.source_thread_required, &source.publish_mode),
181            join_actions(&source.allowed_actions)
182        ));
183    }
184}
185
186fn push_targets(lines: &mut Vec<String>, result: &PolicyCommandResult) {
187    if result.policy.targets.is_empty() {
188        return;
189    }
190    lines.push(String::new());
191    lines.push("  targets".to_owned());
192    for target in &result.policy.targets {
193        lines.push(format!(
194            "  - {}: runners={}; available={}; owners={}; actions={}",
195            target.repo,
196            target.runner_ids.join(","),
197            target.available_runner_count,
198            target.owner_count,
199            join_actions(&target.allowed_actions)
200        ));
201    }
202}
203
204fn push_findings(lines: &mut Vec<String>, findings: &[OperationalPolicyValidationFinding]) {
205    if findings.is_empty() {
206        return;
207    }
208    lines.push(String::new());
209    lines.push("  findings".to_owned());
210    for finding in findings {
211        lines.push(format!(
212            "  - {} {}: {}",
213            finding.code, finding.path, finding.message
214        ));
215    }
216}
217
218fn render_key_value_rows(rows: &[(&str, String)]) -> Vec<String> {
219    rows.iter()
220        .map(|(key, value)| format!("  {key:<9} {value}"))
221        .collect()
222}
223
224fn status_icon(status: &str) -> &'static str {
225    if status == "success" { "ok" } else { "fail" }
226}
227
228fn policy_action_name(action: PolicyAction) -> &'static str {
229    match action {
230        PolicyAction::Inspect => "inspect",
231        PolicyAction::Lint => "lint",
232    }
233}
234
235fn source_thread_label(
236    required: bool,
237    mode: &runx_contracts::OperationalPolicyPublishMode,
238) -> String {
239    if required {
240        mode.to_string()
241    } else {
242        "not-required".to_owned()
243    }
244}
245
246fn join_actions(actions: &[runx_contracts::OperationalPolicyAction]) -> String {
247    actions
248        .iter()
249        .map(ToString::to_string)
250        .collect::<Vec<_>>()
251        .join(",")
252}
253
254fn resolve_policy_path(path: &Path, env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
255    if path.is_absolute() {
256        return path.to_path_buf();
257    }
258    env.get("RUNX_CWD")
259        .map(PathBuf::from)
260        .or_else(|| env.get("INIT_CWD").map(PathBuf::from))
261        .unwrap_or_else(|| cwd.to_path_buf())
262        .join(path)
263}
264
265fn display_policy_path(path: &Path, env: &BTreeMap<String, String>, cwd: &Path) -> String {
266    let base = env
267        .get("RUNX_CWD")
268        .map(PathBuf::from)
269        .or_else(|| env.get("INIT_CWD").map(PathBuf::from))
270        .unwrap_or_else(|| cwd.to_path_buf());
271    path.strip_prefix(&base)
272        .map(|relative| relative.to_string_lossy().replace('\\', "/"))
273        .unwrap_or_else(|_| {
274            path.file_name()
275                .map(|name| name.to_string_lossy().into_owned())
276                .unwrap_or_else(|| path.to_string_lossy().into_owned())
277        })
278}