Skip to main content

agentctl/debug/
bundle.rs

1use super::schema::{
2    BUNDLE_ARTIFACTS_DIR, BUNDLE_MANIFEST_FILE_NAME, BundleArtifact, BundleManifest,
3};
4use super::sources::{git_context, image_processing, macos_agent, screen_record};
5use super::{EXIT_OK, EXIT_RUNTIME_ERROR};
6use clap::{Args, ValueEnum};
7use serde::Serialize;
8use serde_json::json;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12const DEFAULT_OUTPUT_NAMESPACE: &str = "agentctl-debug-bundle";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
15pub enum BundleOutputFormat {
16    Text,
17    #[default]
18    Json,
19}
20
21#[derive(Debug, Args)]
22pub struct BundleArgs {
23    /// Output directory for the manifest and normalized artifacts
24    #[arg(long)]
25    pub output_dir: Option<PathBuf>,
26
27    /// Render format for command output
28    #[arg(long, value_enum, default_value_t = BundleOutputFormat::Json)]
29    pub format: BundleOutputFormat,
30}
31
32#[derive(Debug, Serialize)]
33struct BundleCommandOutput<'a> {
34    manifest_path: String,
35    manifest: &'a BundleManifest,
36}
37
38#[derive(Debug)]
39struct CommandCapture {
40    success: bool,
41    exit_code: Option<i32>,
42    stdout: String,
43    stderr: String,
44    spawn_error: Option<String>,
45}
46
47#[derive(Debug, Serialize)]
48struct CommandInvocation<'a> {
49    program: &'a str,
50    args: Vec<&'a str>,
51}
52
53#[derive(Debug, Serialize)]
54struct CommandArtifactPayload<'a> {
55    source: &'a str,
56    command: CommandInvocation<'a>,
57    success: bool,
58    exit_code: Option<i32>,
59    stdout: String,
60    stderr: String,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    spawn_error: Option<String>,
63}
64
65pub fn run(args: BundleArgs) -> i32 {
66    let output_dir = resolve_output_dir(args.output_dir.as_deref());
67    let manifest = match collect_bundle(&output_dir) {
68        Ok(manifest) => manifest,
69        Err(error) => {
70            eprintln!("agentctl debug bundle: {error}");
71            return EXIT_RUNTIME_ERROR;
72        }
73    };
74
75    let manifest_path = manifest_path_for(&output_dir);
76    let output = BundleCommandOutput {
77        manifest_path: path_to_string(&manifest_path),
78        manifest: &manifest,
79    };
80
81    match args.format {
82        BundleOutputFormat::Json => emit_json(&output),
83        BundleOutputFormat::Text => {
84            emit_text(&output);
85            EXIT_OK
86        }
87    }
88}
89
90pub fn collect_bundle(output_dir: &Path) -> Result<BundleManifest, String> {
91    std::fs::create_dir_all(output_dir).map_err(|error| {
92        format!(
93            "failed to create output directory `{}`: {error}",
94            output_dir.display()
95        )
96    })?;
97    std::fs::create_dir_all(output_dir.join(BUNDLE_ARTIFACTS_DIR)).map_err(|error| {
98        format!(
99            "failed to create artifact directory `{}`: {error}",
100            output_dir.join(BUNDLE_ARTIFACTS_DIR).display()
101        )
102    })?;
103
104    let artifacts = vec![
105        git_context::collect(output_dir),
106        macos_agent::collect(output_dir),
107        screen_record::collect(output_dir),
108        image_processing::collect(output_dir),
109    ];
110    let manifest = BundleManifest::from_artifacts(path_to_string(output_dir), artifacts);
111    let manifest_path = manifest_path_for(output_dir);
112    write_json_file(&manifest_path, &manifest).map_err(|error| {
113        format!(
114            "failed to write manifest `{}`: {error}",
115            manifest_path.display()
116        )
117    })?;
118
119    Ok(manifest)
120}
121
122pub fn resolve_output_dir(explicit: Option<&Path>) -> PathBuf {
123    if let Some(path) = explicit {
124        return path.to_path_buf();
125    }
126
127    agents_out_dir().join(DEFAULT_OUTPUT_NAMESPACE)
128}
129
130pub(crate) fn collect_command_artifact(
131    output_dir: &Path,
132    id: &'static str,
133    relative_path: &'static str,
134    program: &'static str,
135    args: &[&'static str],
136) -> BundleArtifact {
137    let capture = run_command(program, args);
138    let payload = CommandArtifactPayload {
139        source: id,
140        command: CommandInvocation {
141            program,
142            args: args.to_vec(),
143        },
144        success: capture.success,
145        exit_code: capture.exit_code,
146        stdout: capture.stdout.clone(),
147        stderr: capture.stderr.clone(),
148        spawn_error: capture.spawn_error.clone(),
149    };
150
151    let normalized_relative_path = normalize_relative_path(relative_path);
152    let mut issues = Vec::new();
153    if let Err(error) = write_json_artifact(output_dir, &normalized_relative_path, &payload) {
154        issues.push(format!("failed to persist command artifact: {error}"));
155    }
156
157    if !capture.success {
158        issues.push(command_failure_message(program, &capture));
159    }
160
161    if issues.is_empty() {
162        BundleArtifact::collected(id, normalized_relative_path)
163    } else {
164        BundleArtifact::failed(id, normalized_relative_path, issues.join("; "))
165    }
166}
167
168pub(crate) fn write_json_artifact<T: Serialize>(
169    output_dir: &Path,
170    relative_path: &str,
171    payload: &T,
172) -> Result<(), String> {
173    let path = output_dir.join(relative_path);
174    write_json_file(&path, payload).map_err(|error| format!("{}: {error}", path.display()))
175}
176
177pub(crate) fn normalize_relative_path(path: &str) -> String {
178    path.trim_start_matches("./").replace('\\', "/")
179}
180
181fn emit_json(output: &BundleCommandOutput<'_>) -> i32 {
182    match serde_json::to_string_pretty(output) {
183        Ok(encoded) => {
184            println!("{encoded}");
185            EXIT_OK
186        }
187        Err(error) => {
188            eprintln!("agentctl debug bundle: failed to render json output: {error}");
189            EXIT_RUNTIME_ERROR
190        }
191    }
192}
193
194fn emit_text(output: &BundleCommandOutput<'_>) {
195    println!("manifest_path: {}", output.manifest_path);
196    println!("schema_version: {}", output.manifest.schema_version);
197    println!("manifest_version: {}", output.manifest.manifest_version);
198    println!("output_dir: {}", output.manifest.output_dir);
199    println!("partial_failure: {}", output.manifest.partial_failure);
200    println!(
201        "summary: total={} collected={} failed={}",
202        output.manifest.summary.total_artifacts,
203        output.manifest.summary.collected,
204        output.manifest.summary.failed
205    );
206    println!("artifacts:");
207    for artifact in &output.manifest.artifacts {
208        let line = json!({
209            "id": artifact.id,
210            "path": artifact.path,
211            "status": artifact.status,
212            "error": artifact.error,
213        });
214        println!("- {}", line);
215    }
216}
217
218fn run_command(program: &str, args: &[&str]) -> CommandCapture {
219    match Command::new(program).args(args).output() {
220        Ok(output) => CommandCapture {
221            success: output.status.success(),
222            exit_code: output.status.code(),
223            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
224            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
225            spawn_error: None,
226        },
227        Err(error) => CommandCapture {
228            success: false,
229            exit_code: None,
230            stdout: String::new(),
231            stderr: String::new(),
232            spawn_error: Some(error.to_string()),
233        },
234    }
235}
236
237fn command_failure_message(program: &str, capture: &CommandCapture) -> String {
238    if let Some(spawn_error) = capture.spawn_error.as_deref() {
239        return format!("failed to launch `{program}`: {spawn_error}");
240    }
241
242    let mut message = match capture.exit_code {
243        Some(code) => format!("`{program}` exited with status code {code}"),
244        None => format!("`{program}` terminated without an exit code"),
245    };
246
247    let stderr_excerpt = capture.stderr.trim();
248    if !stderr_excerpt.is_empty() {
249        message.push_str(": ");
250        message.push_str(stderr_excerpt.replace('\n', " ").as_str());
251    }
252
253    message
254}
255
256fn write_json_file<T: Serialize>(path: &Path, payload: &T) -> Result<(), String> {
257    if let Some(parent) = path.parent() {
258        std::fs::create_dir_all(parent).map_err(|error| {
259            format!(
260                "failed to create parent dir `{}`: {error}",
261                parent.display()
262            )
263        })?;
264    }
265    let body = serde_json::to_vec_pretty(payload)
266        .map_err(|error| format!("failed to serialize json payload: {error}"))?;
267    std::fs::write(path, body).map_err(|error| format!("failed to write file: {error}"))
268}
269
270fn manifest_path_for(output_dir: &Path) -> PathBuf {
271    output_dir.join(BUNDLE_MANIFEST_FILE_NAME)
272}
273
274fn path_to_string(path: &Path) -> String {
275    path.to_string_lossy().to_string()
276}
277
278fn agents_out_dir() -> PathBuf {
279    if let Ok(agents_home) = std::env::var("AGENTS_HOME") {
280        return PathBuf::from(agents_home).join("out");
281    }
282    if let Some(home) = std::env::var_os("HOME") {
283        return PathBuf::from(home).join(".agents").join("out");
284    }
285    PathBuf::from(".agents").join("out")
286}