Skip to main content

leviath_cli/commands/
pack.rs

1//! `lev pack` - Bundle an agent project for distribution.
2
3use clap::Args;
4use leviath_package::AgentBundler;
5use std::path::{Path, PathBuf};
6
7use leviath_core::manifest::parse_manifest;
8
9/// Arguments for `lev pack`.
10#[derive(Args)]
11pub struct PackArgs {
12    /// Path to agent project (default: current directory)
13    #[arg(value_name = "PATH")]
14    pub path: Option<String>,
15
16    /// Output file path (default: {name}-{version}.leviath-bundle)
17    #[arg(short, long)]
18    pub output: Option<String>,
19}
20
21/// Run `lev pack`: build a distributable bundle from an agent directory.
22pub async fn execute(args: PackArgs) -> anyhow::Result<()> {
23    execute_with_bundle(args, &|dir| AgentBundler::new().bundle(dir)).await
24}
25
26/// [`execute`] with an injectable bundling operation.
27///
28/// The `bundle` closure is a trait object so its failure arm (`bundler.bundle`
29/// erroring) can be exercised on every platform. Reaching that arm through the
30/// real bundler requires the directory walk itself to fail, which is only
31/// possible OS-agnostically via injection inside `leviath-package`; here the
32/// simpler seam is to inject the whole bundle step. Production always passes
33/// the real `AgentBundler`.
34async fn execute_with_bundle(
35    args: PackArgs,
36    bundle: &dyn Fn(&Path) -> anyhow::Result<Vec<u8>>,
37) -> anyhow::Result<()> {
38    let path = args.path.unwrap_or_else(|| ".".to_string());
39    let project_path = Path::new(&path);
40
41    tracing::info!("Packing agent");
42
43    // Find and parse agent.leviath to get name + version
44    let manifest_path = find_manifest(project_path)?;
45    let manifest_content = std::fs::read_to_string(&manifest_path)
46        .map_err(|e| anyhow::anyhow!("Failed to read manifest: {}", e))?;
47    let blueprint = parse_manifest(&manifest_content)?;
48
49    println!("Packing agent: {} v{}", blueprint.name, blueprint.version);
50
51    // Determine output path
52    let output_path =
53        determine_output_path(args.output.as_deref(), &blueprint.name, &blueprint.version);
54
55    // Bundle the project
56    let project_dir = manifest_path.parent().unwrap_or(Path::new("."));
57
58    let data = bundle(project_dir)?;
59    let bundle_size = data.len();
60
61    std::fs::write(&output_path, &data).map_err(|e| {
62        anyhow::anyhow!(
63            "Failed to write bundle to '{}': {}",
64            output_path.display(),
65            e
66        )
67    })?;
68
69    // Print summary
70    println!("Bundle written to: {}", output_path.display());
71    println!("Bundle size: {}", format_size(bundle_size));
72
73    // List contents summary
74    println!("\nContents:");
75    let file_count = count_files(project_dir);
76    println!("  {} files bundled", file_count);
77    println!("  Manifest: agent.leviath");
78
79    let scripts_dir = project_dir.join("scripts");
80    if scripts_dir.exists() {
81        let script_count = count_files(&scripts_dir);
82        println!("  Scripts: {} files", script_count);
83    }
84
85    let tests_dir = project_dir.join("tests");
86    if tests_dir.exists() {
87        let test_count = count_files(&tests_dir);
88        println!("  Tests: {} files", test_count);
89    }
90
91    println!("\nDone! Install with: lev add {}", output_path.display());
92
93    Ok(())
94}
95
96/// Resolves the bundle output path.
97fn determine_output_path(output: Option<&str>, name: &str, version: &str) -> PathBuf {
98    match output {
99        Some(out) => PathBuf::from(out),
100        None => PathBuf::from(format!("{}-{}.leviath-bundle", name, version)),
101    }
102}
103
104fn find_manifest(project_path: &Path) -> anyhow::Result<PathBuf> {
105    find_manifest_with_cwd(project_path, &std::env::current_dir().unwrap_or_default())
106}
107
108fn find_manifest_with_cwd(project_path: &Path, cwd: &Path) -> anyhow::Result<PathBuf> {
109    if project_path.is_file()
110        && project_path.file_name() == Some(std::ffi::OsStr::new("agent.leviath"))
111    {
112        return Ok(project_path.to_path_buf());
113    }
114
115    if project_path.is_dir() {
116        let manifest = project_path.join("agent.leviath");
117        if manifest.exists() {
118            return Ok(manifest);
119        }
120    }
121
122    let current_manifest = cwd.join("agent.leviath");
123    if current_manifest.exists() {
124        return Ok(current_manifest);
125    }
126
127    anyhow::bail!(
128        "Could not find agent.leviath in {} or current directory",
129        project_path.display()
130    )
131}
132
133fn format_size(bytes: usize) -> String {
134    if bytes < 1024 {
135        format!("{} B", bytes)
136    } else if bytes < 1024 * 1024 {
137        format!("{:.1} KB", bytes as f64 / 1024.0)
138    } else {
139        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
140    }
141}
142
143/// The real directory reader used in production. A named function (rather than
144/// an inline closure) so tests that need to pass a reader which their code path
145/// never actually invokes don't introduce an uncovered closure-body region.
146fn real_read_dir(p: &Path) -> std::io::Result<std::fs::ReadDir> {
147    std::fs::read_dir(p)
148}
149
150/// Count files in `dir` recursively; returns 0 on I/O errors.
151fn count_files(dir: &Path) -> usize {
152    count_files_with(dir, &real_read_dir)
153}
154
155/// [`count_files`] with an injectable directory reader so the "read_dir failed
156/// on an existing directory -> return the count so far" arm can be exercised
157/// deterministically on every platform. That arm is otherwise only reachable
158/// via a `chmod 0o000` directory (Unix-only). A trait object (not `impl Fn`)
159/// keeps every caller sharing one monomorphization. Production always passes
160/// `std::fs::read_dir`.
161fn count_files_with(
162    dir: &Path,
163    read_dir: &dyn Fn(&Path) -> std::io::Result<std::fs::ReadDir>,
164) -> usize {
165    let mut count = 0;
166    if dir.is_dir()
167        && let Ok(entries) = read_dir(dir)
168    {
169        for entry in entries.filter_map(|e| e.ok()) {
170            count += count_path(&entry.path(), read_dir);
171        }
172    }
173    count
174}
175
176/// Count the files contributed by a single walked path: 1 for a regular file,
177/// the recursive count for a directory, and 0 for anything else (a broken
178/// symlink, a special file, or a path that vanished mid-walk). Split out so the
179/// "neither a file nor a directory" arm is testable OS-agnostically (a
180/// nonexistent path is neither), rather than only via a Unix broken symlink.
181fn count_path(path: &Path, read_dir: &dyn Fn(&Path) -> std::io::Result<std::fs::ReadDir>) -> usize {
182    if path.is_file() {
183        1
184    } else if path.is_dir() {
185        count_files_with(path, read_dir)
186    } else {
187        0
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::test_support::with_tracing;
195
196    // ─── tracing subscriber ────────────────────────────────────────────────
197    //
198    // Without a registered subscriber, `tracing::info!`'s macro expansion
199    // short-circuits field evaluation before the "is level enabled" check
200    // runs, so field-expression lines show as uncovered even though the
201    // surrounding branch executes. Each test below calls `with_tracing(|| {})`
202    // once as a bare statement (rather than wrapping the whole test body) to
203    // install the shared `AlwaysOnSubscriber` (see `crate::test_support`) as
204    // the process-wide default before the rest of the test runs.
205
206    // ─── format_size ───────────────────────────────────────────────────────
207
208    #[test]
209    fn format_size_bytes() {
210        assert_eq!(format_size(0), "0 B");
211        assert_eq!(format_size(512), "512 B");
212        assert_eq!(format_size(1023), "1023 B");
213    }
214
215    #[test]
216    fn format_size_kilobytes() {
217        assert_eq!(format_size(1024), "1.0 KB");
218        assert_eq!(format_size(2048), "2.0 KB");
219        assert_eq!(format_size(1536), "1.5 KB");
220    }
221
222    #[test]
223    fn format_size_megabytes() {
224        assert_eq!(format_size(1024 * 1024), "1.0 MB");
225        assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
226    }
227
228    // ─── count_files ───────────────────────────────────────────────────────
229
230    #[test]
231    fn count_files_empty_dir() {
232        let dir = tempfile::tempdir().unwrap();
233        assert_eq!(count_files(dir.path()), 0);
234    }
235
236    #[test]
237    fn count_files_with_files() {
238        let dir = tempfile::tempdir().unwrap();
239        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
240        std::fs::write(dir.path().join("b.txt"), "b").unwrap();
241        assert_eq!(count_files(dir.path()), 2);
242    }
243
244    #[test]
245    fn count_files_nested() {
246        let dir = tempfile::tempdir().unwrap();
247        std::fs::write(dir.path().join("top.txt"), "t").unwrap();
248        std::fs::create_dir_all(dir.path().join("sub")).unwrap();
249        std::fs::write(dir.path().join("sub/nested.txt"), "n").unwrap();
250        assert_eq!(count_files(dir.path()), 2);
251    }
252
253    #[test]
254    fn count_files_non_directory_returns_zero() {
255        let dir = tempfile::tempdir().unwrap();
256        let file = dir.path().join("a.txt");
257        std::fs::write(&file, "hello").unwrap();
258        assert_eq!(count_files(&file), 0);
259    }
260
261    #[test]
262    fn count_files_read_dir_error_returns_zero() {
263        // An injected `read_dir` that fails on an existing directory exercises
264        // the "read_dir errored -> keep the count so far" arm deterministically
265        // on every platform.
266        let dir = tempfile::tempdir().unwrap();
267        let result = count_files_with(dir.path(), &|_| {
268            Err(std::io::Error::other("simulated read_dir failure"))
269        });
270        assert_eq!(result, 0);
271    }
272
273    #[test]
274    fn count_path_neither_file_nor_dir_counts_zero() {
275        // A nonexistent path is neither a file nor a directory, so `count_path`
276        // contributes 0 - covering the `else` arm on every platform.
277        let dir = tempfile::tempdir().unwrap();
278        let missing = dir.path().join("does-not-exist");
279        assert_eq!(count_path(&missing, &real_read_dir), 0);
280    }
281
282    // ─── find_manifest ─────────────────────────────────────────────────────
283
284    #[test]
285    fn find_manifest_in_directory() {
286        let dir = tempfile::tempdir().unwrap();
287        let manifest = dir.path().join("agent.leviath");
288        std::fs::write(&manifest, "name = \"test\"").unwrap();
289        let result = find_manifest(dir.path());
290        assert!(result.is_ok());
291        assert_eq!(result.unwrap(), manifest);
292    }
293
294    #[test]
295    fn find_manifest_direct_file() {
296        let dir = tempfile::tempdir().unwrap();
297        let manifest = dir.path().join("agent.leviath");
298        std::fs::write(&manifest, "name = \"test\"").unwrap();
299        let result = find_manifest(&manifest);
300        assert!(result.is_ok());
301        assert_eq!(result.unwrap(), manifest);
302    }
303
304    #[test]
305    fn find_manifest_not_found_errors() {
306        let dir = tempfile::tempdir().unwrap();
307        let result = find_manifest(dir.path());
308        assert!(result.is_err());
309        assert!(result.unwrap_err().to_string().contains("agent.leviath"));
310    }
311
312    // ─── find_manifest_with_cwd ────────────────────────────────────────────
313
314    #[test]
315    fn find_manifest_with_cwd_finds_in_directory() {
316        let dir = tempfile::tempdir().unwrap();
317        let manifest = dir.path().join("agent.leviath");
318        std::fs::write(&manifest, "name = \"test\"").unwrap();
319        let cwd = tempfile::tempdir().unwrap();
320        let result = find_manifest_with_cwd(dir.path(), cwd.path());
321        assert_eq!(result.unwrap(), manifest);
322    }
323
324    #[test]
325    fn find_manifest_with_cwd_finds_direct_file() {
326        let dir = tempfile::tempdir().unwrap();
327        let manifest = dir.path().join("agent.leviath");
328        std::fs::write(&manifest, "name = \"test\"").unwrap();
329        let cwd = tempfile::tempdir().unwrap();
330        let result = find_manifest_with_cwd(&manifest, cwd.path());
331        assert_eq!(result.unwrap(), manifest);
332    }
333
334    #[test]
335    fn find_manifest_with_cwd_falls_back_to_cwd() {
336        let empty_dir = tempfile::tempdir().unwrap();
337        let cwd_dir = tempfile::tempdir().unwrap();
338        let cwd_manifest = cwd_dir.path().join("agent.leviath");
339        std::fs::write(&cwd_manifest, "name = \"test\"").unwrap();
340        let result = find_manifest_with_cwd(empty_dir.path(), cwd_dir.path());
341        assert_eq!(result.unwrap(), cwd_manifest);
342    }
343
344    #[test]
345    fn find_manifest_with_cwd_errors_when_not_found() {
346        let empty_project = tempfile::tempdir().unwrap();
347        let empty_cwd = tempfile::tempdir().unwrap();
348        let result = find_manifest_with_cwd(empty_project.path(), empty_cwd.path());
349        assert!(result.is_err());
350        assert!(result.unwrap_err().to_string().contains("agent.leviath"));
351    }
352
353    // ─── output path determination ─────────────────────────────────────────
354
355    #[test]
356    fn output_path_from_args() {
357        let output_path =
358            determine_output_path(Some("my-output.leviath-bundle"), "my-agent", "1.0.0");
359        assert_eq!(output_path, PathBuf::from("my-output.leviath-bundle"));
360    }
361
362    #[test]
363    fn output_path_default() {
364        let output_path = determine_output_path(None, "my-agent", "1.0.0");
365        assert_eq!(output_path, PathBuf::from("my-agent-1.0.0.leviath-bundle"));
366    }
367
368    // ─── format_size edge cases ───────────────────────────────────────────
369
370    #[test]
371    fn format_size_boundary_kb() {
372        assert_eq!(format_size(1023), "1023 B");
373        assert_eq!(format_size(1024), "1.0 KB");
374    }
375
376    #[test]
377    fn format_size_boundary_mb() {
378        assert_eq!(format_size(1024 * 1024 - 1), "1024.0 KB");
379        assert_eq!(format_size(1024 * 1024), "1.0 MB");
380    }
381
382    #[test]
383    fn format_size_fractional_kb() {
384        assert_eq!(format_size(1536), "1.5 KB");
385        assert_eq!(format_size(2560), "2.5 KB");
386    }
387
388    // ─── count_files edge cases ───────────────────────────────────────────
389
390    #[test]
391    fn count_files_nested_multiple_levels() {
392        let dir = tempfile::tempdir().unwrap();
393        std::fs::create_dir_all(dir.path().join("a/b/c")).unwrap();
394        std::fs::write(dir.path().join("root.txt"), "r").unwrap();
395        std::fs::write(dir.path().join("a/level1.txt"), "1").unwrap();
396        std::fs::write(dir.path().join("a/b/level2.txt"), "2").unwrap();
397        std::fs::write(dir.path().join("a/b/c/level3.txt"), "3").unwrap();
398        assert_eq!(count_files(dir.path()), 4);
399    }
400
401    // ─── find_manifest edge cases ─────────────────────────────────────────
402
403    #[test]
404    fn find_manifest_nonexistent_path_errors() {
405        let result = find_manifest(Path::new("/tmp/nonexistent-leviath-test-dir"));
406        assert!(result.is_err());
407    }
408
409    // ─── output path generation ───────────────────────────────────────────
410
411    #[test]
412    fn output_path_with_special_chars() {
413        let output_path = determine_output_path(None, "my-agent", "1.0.0-beta.1");
414        assert_eq!(
415            output_path,
416            PathBuf::from("my-agent-1.0.0-beta.1.leviath-bundle")
417        );
418    }
419
420    // ─── execute ─────────────────────────────────────────────────────────
421
422    fn make_project_dir(with_scripts: bool, with_tests: bool) -> tempfile::TempDir {
423        let dir = tempfile::tempdir().unwrap();
424        std::fs::write(
425            dir.path().join("agent.leviath"),
426            "[agent]\nname = \"packed-agent\"\nversion = \"1.0.0\"\ndescription = \"d\"\n",
427        )
428        .unwrap();
429        if with_scripts {
430            std::fs::create_dir_all(dir.path().join("scripts")).unwrap();
431            std::fs::write(dir.path().join("scripts/run.sh"), "#!/bin/sh\n").unwrap();
432        }
433        if with_tests {
434            std::fs::create_dir_all(dir.path().join("tests")).unwrap();
435            std::fs::write(dir.path().join("tests/test1.txt"), "test").unwrap();
436        }
437        dir
438    }
439
440    #[tokio::test]
441    async fn execute_packs_project_to_explicit_output() {
442        with_tracing(|| {});
443        let project = make_project_dir(false, false);
444        let output_dir = tempfile::tempdir().unwrap();
445        let output_path = output_dir.path().join("out.leviath-bundle");
446        let args = PackArgs {
447            path: Some(project.path().to_str().unwrap().to_string()),
448            output: Some(output_path.to_str().unwrap().to_string()),
449        };
450        execute(args).await.unwrap();
451        assert!(output_path.exists());
452        assert!(std::fs::metadata(&output_path).unwrap().len() > 0);
453    }
454
455    #[tokio::test]
456    async fn execute_with_scripts_and_tests_dirs() {
457        with_tracing(|| {});
458        let project = make_project_dir(true, true);
459        let output_dir = tempfile::tempdir().unwrap();
460        let output_path = output_dir.path().join("out.leviath-bundle");
461        let args = PackArgs {
462            path: Some(project.path().to_str().unwrap().to_string()),
463            output: Some(output_path.to_str().unwrap().to_string()),
464        };
465        execute(args).await.unwrap();
466        assert!(output_path.exists());
467    }
468
469    #[tokio::test]
470    async fn execute_missing_manifest_errors() {
471        with_tracing(|| {});
472        let project = tempfile::tempdir().unwrap();
473        let output_dir = tempfile::tempdir().unwrap();
474        let output_path = output_dir.path().join("out.leviath-bundle");
475        let args = PackArgs {
476            path: Some(project.path().to_str().unwrap().to_string()),
477            output: Some(output_path.to_str().unwrap().to_string()),
478        };
479        let err = execute(args).await.unwrap_err();
480        assert!(err.to_string().contains("Could not find agent.leviath"));
481    }
482
483    #[tokio::test]
484    async fn execute_unwritable_output_path_errors() {
485        with_tracing(|| {});
486        let project = make_project_dir(false, false);
487        let output_path = project
488            .path()
489            .join("nonexistent-subdir")
490            .join("out.leviath-bundle");
491        let args = PackArgs {
492            path: Some(project.path().to_str().unwrap().to_string()),
493            output: Some(output_path.to_str().unwrap().to_string()),
494        };
495        let err = execute(args).await.unwrap_err();
496        assert!(err.to_string().contains("Failed to write bundle"));
497    }
498
499    #[tokio::test]
500    async fn execute_with_path_none_falls_back_to_dot() {
501        // args.path = None triggers the unwrap_or_else closure on line 21.
502        with_tracing(|| {});
503        let args = PackArgs {
504            path: None,
505            output: None,
506        };
507        let err = execute(args).await.unwrap_err();
508        assert!(err.to_string().contains("agent.leviath"));
509    }
510
511    #[tokio::test]
512    async fn execute_invalid_manifest_toml_errors() {
513        // Manifest exists but is invalid TOML - covers parse_manifest ? on line 30.
514        with_tracing(|| {});
515        let project = tempfile::tempdir().unwrap();
516        std::fs::write(project.path().join("agent.leviath"), "not valid toml ][").unwrap();
517        let output_dir = tempfile::tempdir().unwrap();
518        let output_path = output_dir.path().join("out.leviath-bundle");
519        let args = PackArgs {
520            path: Some(project.path().to_str().unwrap().to_string()),
521            output: Some(output_path.to_str().unwrap().to_string()),
522        };
523        execute(args).await.unwrap_err();
524    }
525
526    #[tokio::test]
527    async fn execute_unreadable_manifest_errors() {
528        // `agent.leviath` exists but is a *directory*: `find_manifest` returns
529        // it (exists() passes), then `read_to_string` fails on every platform,
530        // covering the "Failed to read manifest" map_err arm.
531        with_tracing(|| {});
532        let project = tempfile::tempdir().unwrap();
533        std::fs::create_dir_all(project.path().join("agent.leviath")).unwrap();
534        let output_dir = tempfile::tempdir().unwrap();
535        let output_path = output_dir.path().join("out.leviath-bundle");
536        let args = PackArgs {
537            path: Some(project.path().to_str().unwrap().to_string()),
538            output: Some(output_path.to_str().unwrap().to_string()),
539        };
540        let e = execute(args).await.unwrap_err();
541        assert!(e.to_string().contains("Failed to read manifest"));
542    }
543
544    #[tokio::test]
545    async fn execute_bundle_error_propagated() {
546        // Inject a bundling op that fails, covering the `bundle(project_dir)?`
547        // error arm on every platform.
548        with_tracing(|| {});
549        let project = make_project_dir(false, false);
550        let output_dir = tempfile::tempdir().unwrap();
551        let output_path = output_dir.path().join("out.leviath-bundle");
552        let args = PackArgs {
553            path: Some(project.path().to_str().unwrap().to_string()),
554            output: Some(output_path.to_str().unwrap().to_string()),
555        };
556        let result =
557            execute_with_bundle(args, &|_| Err(anyhow::anyhow!("simulated bundle failure"))).await;
558        let e = result.unwrap_err();
559        assert!(e.to_string().contains("simulated bundle failure"));
560    }
561}