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}