1use clap::Args;
4use leviath_package::AgentBundler;
5use std::path::{Path, PathBuf};
6
7use leviath_core::manifest::parse_manifest;
8
9#[derive(Args)]
10pub struct PackArgs {
11 #[arg(value_name = "PATH")]
13 pub path: Option<String>,
14
15 #[arg(short, long)]
17 pub output: Option<String>,
18}
19
20pub async fn execute(args: PackArgs) -> anyhow::Result<()> {
21 execute_with_bundle(args, &|dir| AgentBundler::new().bundle(dir)).await
22}
23
24async fn execute_with_bundle(
33 args: PackArgs,
34 bundle: &dyn Fn(&Path) -> anyhow::Result<Vec<u8>>,
35) -> anyhow::Result<()> {
36 let path = args.path.unwrap_or_else(|| ".".to_string());
37 let project_path = Path::new(&path);
38
39 tracing::info!("Packing agent");
40
41 let manifest_path = find_manifest(project_path)?;
43 let manifest_content = std::fs::read_to_string(&manifest_path)
44 .map_err(|e| anyhow::anyhow!("Failed to read manifest: {}", e))?;
45 let blueprint = parse_manifest(&manifest_content)?;
46
47 println!("Packing agent: {} v{}", blueprint.name, blueprint.version);
48
49 let output_path =
51 determine_output_path(args.output.as_deref(), &blueprint.name, &blueprint.version);
52
53 let project_dir = manifest_path.parent().unwrap_or(Path::new("."));
55
56 let data = bundle(project_dir)?;
57 let bundle_size = data.len();
58
59 std::fs::write(&output_path, &data).map_err(|e| {
60 anyhow::anyhow!(
61 "Failed to write bundle to '{}': {}",
62 output_path.display(),
63 e
64 )
65 })?;
66
67 println!("Bundle written to: {}", output_path.display());
69 println!("Bundle size: {}", format_size(bundle_size));
70
71 println!("\nContents:");
73 let file_count = count_files(project_dir);
74 println!(" {} files bundled", file_count);
75 println!(" Manifest: agent.leviath");
76
77 let scripts_dir = project_dir.join("scripts");
78 if scripts_dir.exists() {
79 let script_count = count_files(&scripts_dir);
80 println!(" Scripts: {} files", script_count);
81 }
82
83 let tests_dir = project_dir.join("tests");
84 if tests_dir.exists() {
85 let test_count = count_files(&tests_dir);
86 println!(" Tests: {} files", test_count);
87 }
88
89 println!("\nDone! Install with: lev add {}", output_path.display());
90
91 Ok(())
92}
93
94fn determine_output_path(output: Option<&str>, name: &str, version: &str) -> PathBuf {
96 match output {
97 Some(out) => PathBuf::from(out),
98 None => PathBuf::from(format!("{}-{}.leviath-bundle", name, version)),
99 }
100}
101
102fn find_manifest(project_path: &Path) -> anyhow::Result<PathBuf> {
103 find_manifest_with_cwd(project_path, &std::env::current_dir().unwrap_or_default())
104}
105
106fn find_manifest_with_cwd(project_path: &Path, cwd: &Path) -> anyhow::Result<PathBuf> {
107 if project_path.is_file()
108 && project_path.file_name() == Some(std::ffi::OsStr::new("agent.leviath"))
109 {
110 return Ok(project_path.to_path_buf());
111 }
112
113 if project_path.is_dir() {
114 let manifest = project_path.join("agent.leviath");
115 if manifest.exists() {
116 return Ok(manifest);
117 }
118 }
119
120 let current_manifest = cwd.join("agent.leviath");
121 if current_manifest.exists() {
122 return Ok(current_manifest);
123 }
124
125 anyhow::bail!(
126 "Could not find agent.leviath in {} or current directory",
127 project_path.display()
128 )
129}
130
131fn format_size(bytes: usize) -> String {
132 if bytes < 1024 {
133 format!("{} B", bytes)
134 } else if bytes < 1024 * 1024 {
135 format!("{:.1} KB", bytes as f64 / 1024.0)
136 } else {
137 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
138 }
139}
140
141fn real_read_dir(p: &Path) -> std::io::Result<std::fs::ReadDir> {
145 std::fs::read_dir(p)
146}
147
148fn count_files(dir: &Path) -> usize {
150 count_files_with(dir, &real_read_dir)
151}
152
153fn count_files_with(
160 dir: &Path,
161 read_dir: &dyn Fn(&Path) -> std::io::Result<std::fs::ReadDir>,
162) -> usize {
163 let mut count = 0;
164 if dir.is_dir()
165 && let Ok(entries) = read_dir(dir)
166 {
167 for entry in entries.filter_map(|e| e.ok()) {
168 count += count_path(&entry.path(), read_dir);
169 }
170 }
171 count
172}
173
174fn count_path(path: &Path, read_dir: &dyn Fn(&Path) -> std::io::Result<std::fs::ReadDir>) -> usize {
180 if path.is_file() {
181 1
182 } else if path.is_dir() {
183 count_files_with(path, read_dir)
184 } else {
185 0
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::test_support::with_tracing;
193
194 #[test]
207 fn format_size_bytes() {
208 assert_eq!(format_size(0), "0 B");
209 assert_eq!(format_size(512), "512 B");
210 assert_eq!(format_size(1023), "1023 B");
211 }
212
213 #[test]
214 fn format_size_kilobytes() {
215 assert_eq!(format_size(1024), "1.0 KB");
216 assert_eq!(format_size(2048), "2.0 KB");
217 assert_eq!(format_size(1536), "1.5 KB");
218 }
219
220 #[test]
221 fn format_size_megabytes() {
222 assert_eq!(format_size(1024 * 1024), "1.0 MB");
223 assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
224 }
225
226 #[test]
229 fn count_files_empty_dir() {
230 let dir = tempfile::tempdir().unwrap();
231 assert_eq!(count_files(dir.path()), 0);
232 }
233
234 #[test]
235 fn count_files_with_files() {
236 let dir = tempfile::tempdir().unwrap();
237 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
238 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
239 assert_eq!(count_files(dir.path()), 2);
240 }
241
242 #[test]
243 fn count_files_nested() {
244 let dir = tempfile::tempdir().unwrap();
245 std::fs::write(dir.path().join("top.txt"), "t").unwrap();
246 std::fs::create_dir_all(dir.path().join("sub")).unwrap();
247 std::fs::write(dir.path().join("sub/nested.txt"), "n").unwrap();
248 assert_eq!(count_files(dir.path()), 2);
249 }
250
251 #[test]
252 fn count_files_non_directory_returns_zero() {
253 let dir = tempfile::tempdir().unwrap();
254 let file = dir.path().join("a.txt");
255 std::fs::write(&file, "hello").unwrap();
256 assert_eq!(count_files(&file), 0);
257 }
258
259 #[test]
260 fn count_files_read_dir_error_returns_zero() {
261 let dir = tempfile::tempdir().unwrap();
265 let result = count_files_with(dir.path(), &|_| {
266 Err(std::io::Error::other("simulated read_dir failure"))
267 });
268 assert_eq!(result, 0);
269 }
270
271 #[test]
272 fn count_path_neither_file_nor_dir_counts_zero() {
273 let dir = tempfile::tempdir().unwrap();
276 let missing = dir.path().join("does-not-exist");
277 assert_eq!(count_path(&missing, &real_read_dir), 0);
278 }
279
280 #[test]
283 fn find_manifest_in_directory() {
284 let dir = tempfile::tempdir().unwrap();
285 let manifest = dir.path().join("agent.leviath");
286 std::fs::write(&manifest, "name = \"test\"").unwrap();
287 let result = find_manifest(dir.path());
288 assert!(result.is_ok());
289 assert_eq!(result.unwrap(), manifest);
290 }
291
292 #[test]
293 fn find_manifest_direct_file() {
294 let dir = tempfile::tempdir().unwrap();
295 let manifest = dir.path().join("agent.leviath");
296 std::fs::write(&manifest, "name = \"test\"").unwrap();
297 let result = find_manifest(&manifest);
298 assert!(result.is_ok());
299 assert_eq!(result.unwrap(), manifest);
300 }
301
302 #[test]
303 fn find_manifest_not_found_errors() {
304 let dir = tempfile::tempdir().unwrap();
305 let result = find_manifest(dir.path());
306 assert!(result.is_err());
307 assert!(result.unwrap_err().to_string().contains("agent.leviath"));
308 }
309
310 #[test]
313 fn find_manifest_with_cwd_finds_in_directory() {
314 let dir = tempfile::tempdir().unwrap();
315 let manifest = dir.path().join("agent.leviath");
316 std::fs::write(&manifest, "name = \"test\"").unwrap();
317 let cwd = tempfile::tempdir().unwrap();
318 let result = find_manifest_with_cwd(dir.path(), cwd.path());
319 assert_eq!(result.unwrap(), manifest);
320 }
321
322 #[test]
323 fn find_manifest_with_cwd_finds_direct_file() {
324 let dir = tempfile::tempdir().unwrap();
325 let manifest = dir.path().join("agent.leviath");
326 std::fs::write(&manifest, "name = \"test\"").unwrap();
327 let cwd = tempfile::tempdir().unwrap();
328 let result = find_manifest_with_cwd(&manifest, cwd.path());
329 assert_eq!(result.unwrap(), manifest);
330 }
331
332 #[test]
333 fn find_manifest_with_cwd_falls_back_to_cwd() {
334 let empty_dir = tempfile::tempdir().unwrap();
335 let cwd_dir = tempfile::tempdir().unwrap();
336 let cwd_manifest = cwd_dir.path().join("agent.leviath");
337 std::fs::write(&cwd_manifest, "name = \"test\"").unwrap();
338 let result = find_manifest_with_cwd(empty_dir.path(), cwd_dir.path());
339 assert_eq!(result.unwrap(), cwd_manifest);
340 }
341
342 #[test]
343 fn find_manifest_with_cwd_errors_when_not_found() {
344 let empty_project = tempfile::tempdir().unwrap();
345 let empty_cwd = tempfile::tempdir().unwrap();
346 let result = find_manifest_with_cwd(empty_project.path(), empty_cwd.path());
347 assert!(result.is_err());
348 assert!(result.unwrap_err().to_string().contains("agent.leviath"));
349 }
350
351 #[test]
354 fn output_path_from_args() {
355 let output_path =
356 determine_output_path(Some("my-output.leviath-bundle"), "my-agent", "1.0.0");
357 assert_eq!(output_path, PathBuf::from("my-output.leviath-bundle"));
358 }
359
360 #[test]
361 fn output_path_default() {
362 let output_path = determine_output_path(None, "my-agent", "1.0.0");
363 assert_eq!(output_path, PathBuf::from("my-agent-1.0.0.leviath-bundle"));
364 }
365
366 #[test]
369 fn format_size_boundary_kb() {
370 assert_eq!(format_size(1023), "1023 B");
371 assert_eq!(format_size(1024), "1.0 KB");
372 }
373
374 #[test]
375 fn format_size_boundary_mb() {
376 assert_eq!(format_size(1024 * 1024 - 1), "1024.0 KB");
377 assert_eq!(format_size(1024 * 1024), "1.0 MB");
378 }
379
380 #[test]
381 fn format_size_fractional_kb() {
382 assert_eq!(format_size(1536), "1.5 KB");
383 assert_eq!(format_size(2560), "2.5 KB");
384 }
385
386 #[test]
389 fn count_files_nested_multiple_levels() {
390 let dir = tempfile::tempdir().unwrap();
391 std::fs::create_dir_all(dir.path().join("a/b/c")).unwrap();
392 std::fs::write(dir.path().join("root.txt"), "r").unwrap();
393 std::fs::write(dir.path().join("a/level1.txt"), "1").unwrap();
394 std::fs::write(dir.path().join("a/b/level2.txt"), "2").unwrap();
395 std::fs::write(dir.path().join("a/b/c/level3.txt"), "3").unwrap();
396 assert_eq!(count_files(dir.path()), 4);
397 }
398
399 #[test]
402 fn find_manifest_nonexistent_path_errors() {
403 let result = find_manifest(Path::new("/tmp/nonexistent-leviath-test-dir"));
404 assert!(result.is_err());
405 }
406
407 #[test]
410 fn output_path_with_special_chars() {
411 let output_path = determine_output_path(None, "my-agent", "1.0.0-beta.1");
412 assert_eq!(
413 output_path,
414 PathBuf::from("my-agent-1.0.0-beta.1.leviath-bundle")
415 );
416 }
417
418 fn make_project_dir(with_scripts: bool, with_tests: bool) -> tempfile::TempDir {
421 let dir = tempfile::tempdir().unwrap();
422 std::fs::write(
423 dir.path().join("agent.leviath"),
424 "[agent]\nname = \"packed-agent\"\nversion = \"1.0.0\"\ndescription = \"d\"\n",
425 )
426 .unwrap();
427 if with_scripts {
428 std::fs::create_dir_all(dir.path().join("scripts")).unwrap();
429 std::fs::write(dir.path().join("scripts/run.sh"), "#!/bin/sh\n").unwrap();
430 }
431 if with_tests {
432 std::fs::create_dir_all(dir.path().join("tests")).unwrap();
433 std::fs::write(dir.path().join("tests/test1.txt"), "test").unwrap();
434 }
435 dir
436 }
437
438 #[tokio::test]
439 async fn execute_packs_project_to_explicit_output() {
440 with_tracing(|| {});
441 let project = make_project_dir(false, false);
442 let output_dir = tempfile::tempdir().unwrap();
443 let output_path = output_dir.path().join("out.leviath-bundle");
444 let args = PackArgs {
445 path: Some(project.path().to_str().unwrap().to_string()),
446 output: Some(output_path.to_str().unwrap().to_string()),
447 };
448 execute(args).await.unwrap();
449 assert!(output_path.exists());
450 assert!(std::fs::metadata(&output_path).unwrap().len() > 0);
451 }
452
453 #[tokio::test]
454 async fn execute_with_scripts_and_tests_dirs() {
455 with_tracing(|| {});
456 let project = make_project_dir(true, true);
457 let output_dir = tempfile::tempdir().unwrap();
458 let output_path = output_dir.path().join("out.leviath-bundle");
459 let args = PackArgs {
460 path: Some(project.path().to_str().unwrap().to_string()),
461 output: Some(output_path.to_str().unwrap().to_string()),
462 };
463 execute(args).await.unwrap();
464 assert!(output_path.exists());
465 }
466
467 #[tokio::test]
468 async fn execute_missing_manifest_errors() {
469 with_tracing(|| {});
470 let project = tempfile::tempdir().unwrap();
471 let output_dir = tempfile::tempdir().unwrap();
472 let output_path = output_dir.path().join("out.leviath-bundle");
473 let args = PackArgs {
474 path: Some(project.path().to_str().unwrap().to_string()),
475 output: Some(output_path.to_str().unwrap().to_string()),
476 };
477 let err = execute(args).await.unwrap_err();
478 assert!(err.to_string().contains("Could not find agent.leviath"));
479 }
480
481 #[tokio::test]
482 async fn execute_unwritable_output_path_errors() {
483 with_tracing(|| {});
484 let project = make_project_dir(false, false);
485 let output_path = project
486 .path()
487 .join("nonexistent-subdir")
488 .join("out.leviath-bundle");
489 let args = PackArgs {
490 path: Some(project.path().to_str().unwrap().to_string()),
491 output: Some(output_path.to_str().unwrap().to_string()),
492 };
493 let err = execute(args).await.unwrap_err();
494 assert!(err.to_string().contains("Failed to write bundle"));
495 }
496
497 #[tokio::test]
498 async fn execute_with_path_none_falls_back_to_dot() {
499 with_tracing(|| {});
501 let args = PackArgs {
502 path: None,
503 output: None,
504 };
505 let err = execute(args).await.unwrap_err();
506 assert!(err.to_string().contains("agent.leviath"));
507 }
508
509 #[tokio::test]
510 async fn execute_invalid_manifest_toml_errors() {
511 with_tracing(|| {});
513 let project = tempfile::tempdir().unwrap();
514 std::fs::write(project.path().join("agent.leviath"), "not valid toml ][").unwrap();
515 let output_dir = tempfile::tempdir().unwrap();
516 let output_path = output_dir.path().join("out.leviath-bundle");
517 let args = PackArgs {
518 path: Some(project.path().to_str().unwrap().to_string()),
519 output: Some(output_path.to_str().unwrap().to_string()),
520 };
521 execute(args).await.unwrap_err();
522 }
523
524 #[tokio::test]
525 async fn execute_unreadable_manifest_errors() {
526 with_tracing(|| {});
530 let project = tempfile::tempdir().unwrap();
531 std::fs::create_dir_all(project.path().join("agent.leviath")).unwrap();
532 let output_dir = tempfile::tempdir().unwrap();
533 let output_path = output_dir.path().join("out.leviath-bundle");
534 let args = PackArgs {
535 path: Some(project.path().to_str().unwrap().to_string()),
536 output: Some(output_path.to_str().unwrap().to_string()),
537 };
538 let e = execute(args).await.unwrap_err();
539 assert!(e.to_string().contains("Failed to read manifest"));
540 }
541
542 #[tokio::test]
543 async fn execute_bundle_error_propagated() {
544 with_tracing(|| {});
547 let project = make_project_dir(false, false);
548 let output_dir = tempfile::tempdir().unwrap();
549 let output_path = output_dir.path().join("out.leviath-bundle");
550 let args = PackArgs {
551 path: Some(project.path().to_str().unwrap().to_string()),
552 output: Some(output_path.to_str().unwrap().to_string()),
553 };
554 let result =
555 execute_with_bundle(args, &|_| Err(anyhow::anyhow!("simulated bundle failure"))).await;
556 let e = result.unwrap_err();
557 assert!(e.to_string().contains("simulated bundle failure"));
558 }
559}