1use super::errors::PackageError;
2use super::*;
3
4#[derive(Debug, Clone, Serialize)]
5pub struct PackageCheckReport {
6 pub package_dir: String,
7 pub manifest_path: String,
8 pub name: Option<String>,
9 pub version: Option<String>,
10 pub errors: Vec<PackageCheckDiagnostic>,
11 pub warnings: Vec<PackageCheckDiagnostic>,
12 pub exports: Vec<PackageExportReport>,
13 pub tools: Vec<PackageToolExportReport>,
14 pub skills: Vec<PackageSkillExportReport>,
15}
16
17#[derive(Debug, Clone, Serialize)]
18pub struct PackageCheckDiagnostic {
19 pub field: String,
20 pub message: String,
21}
22
23#[derive(Debug, Clone, Serialize)]
24pub struct PackageExportReport {
25 pub name: String,
26 pub path: String,
27 pub symbols: Vec<PackageApiSymbol>,
28}
29
30#[derive(Debug, Clone, Serialize)]
31pub struct PackageToolExportReport {
32 pub name: String,
33 pub module: String,
34 pub symbol: String,
35 pub permissions: Vec<String>,
36 pub host_requirements: Vec<String>,
37}
38
39#[derive(Debug, Clone, Serialize)]
40pub struct PackageSkillExportReport {
41 pub name: String,
42 pub path: String,
43 pub permissions: Vec<String>,
44 pub host_requirements: Vec<String>,
45}
46
47#[derive(Debug, Clone, Serialize)]
48pub struct PackageApiSymbol {
49 pub kind: String,
50 pub name: String,
51 pub signature: String,
52 pub docs: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize)]
56pub struct PackagePackReport {
57 pub package_dir: String,
58 pub artifact_dir: String,
59 pub dry_run: bool,
60 pub files: Vec<String>,
61 pub check: PackageCheckReport,
62}
63
64#[derive(Debug, Clone, Serialize)]
65pub struct PackagePublishReport {
66 pub dry_run: bool,
67 pub registry: String,
68 pub artifact_dir: String,
69 pub files: Vec<String>,
70 pub check: PackageCheckReport,
71}
72
73#[derive(Debug, Clone, Serialize)]
74pub struct PackageListReport {
75 pub manifest_path: String,
76 pub lock_path: String,
77 pub lock_present: bool,
78 pub dependency_count: usize,
79 pub packages: Vec<PackageListEntry>,
80}
81
82#[derive(Debug, Clone, Serialize)]
83pub struct PackageListEntry {
84 pub name: String,
85 pub source: String,
86 pub package_version: Option<String>,
87 pub harn_compat: Option<String>,
88 pub provenance: Option<String>,
89 pub materialized: bool,
90 pub integrity: String,
91 pub exports: PackageLockExports,
92 pub permissions: Vec<String>,
93 pub host_requirements: Vec<String>,
94}
95
96#[derive(Debug, Clone, Serialize)]
97pub struct PackageDoctorReport {
98 pub ok: bool,
99 pub manifest_path: String,
100 pub lock_path: String,
101 pub diagnostics: Vec<PackageDoctorDiagnostic>,
102 pub packages: Vec<PackageListEntry>,
103}
104
105#[derive(Debug, Clone, Serialize)]
106pub struct PackageDoctorDiagnostic {
107 pub severity: String,
108 pub code: String,
109 pub message: String,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub help: Option<String>,
112}
113
114pub fn check_package(anchor: Option<&Path>, json: bool) {
115 match check_package_impl(anchor) {
116 Ok(report) => {
117 if json {
118 println!(
119 "{}",
120 serde_json::to_string_pretty(&report)
121 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
122 );
123 } else {
124 print_package_check_report(&report);
125 }
126 if !report.errors.is_empty() {
127 process::exit(1);
128 }
129 }
130 Err(error) => {
131 eprintln!("error: {error}");
132 process::exit(1);
133 }
134 }
135}
136
137pub fn pack_package(anchor: Option<&Path>, output: Option<&Path>, dry_run: bool, json: bool) {
138 match pack_package_impl(anchor, output, dry_run) {
139 Ok(report) => {
140 if json {
141 println!(
142 "{}",
143 serde_json::to_string_pretty(&report)
144 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
145 );
146 } else {
147 print_package_pack_report(&report);
148 }
149 }
150 Err(error) => {
151 eprintln!("error: {error}");
152 process::exit(1);
153 }
154 }
155}
156
157pub fn generate_package_docs(anchor: Option<&Path>, output: Option<&Path>, check: bool) {
158 match generate_package_docs_impl(anchor, output, check) {
159 Ok(path) if check => println!("{} is up to date.", path.display()),
160 Ok(path) => println!("Wrote {}.", path.display()),
161 Err(error) => {
162 eprintln!("error: {error}");
163 process::exit(1);
164 }
165 }
166}
167
168pub fn publish_package(anchor: Option<&Path>, dry_run: bool, registry: Option<&str>, json: bool) {
169 if !dry_run {
170 eprintln!(
171 "error: registry submission is not enabled yet; use `harn publish --dry-run` to validate the package and inspect the artifact"
172 );
173 process::exit(1);
174 }
175
176 match publish_package_impl(anchor, registry) {
177 Ok(report) => {
178 if json {
179 println!(
180 "{}",
181 serde_json::to_string_pretty(&report)
182 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
183 );
184 } else {
185 println!("Dry-run publish to {} succeeded.", report.registry);
186 println!("artifact: {}", report.artifact_dir);
187 println!("files: {}", report.files.len());
188 }
189 }
190 Err(error) => {
191 eprintln!("error: {error}");
192 process::exit(1);
193 }
194 }
195}
196
197pub fn list_packages(json: bool) {
198 match list_packages_impl() {
199 Ok(report) if json => {
200 println!(
201 "{}",
202 serde_json::to_string_pretty(&report)
203 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
204 );
205 }
206 Ok(report) => print_package_list_report(&report),
207 Err(error) => {
208 eprintln!("error: {error}");
209 process::exit(1);
210 }
211 }
212}
213
214pub fn doctor_packages(json: bool) {
215 match doctor_packages_impl() {
216 Ok(report) if json => {
217 println!(
218 "{}",
219 serde_json::to_string_pretty(&report)
220 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
221 );
222 if !report.ok {
223 process::exit(1);
224 }
225 }
226 Ok(report) => {
227 print_package_doctor_report(&report);
228 if !report.ok {
229 process::exit(1);
230 }
231 }
232 Err(error) => {
233 eprintln!("error: {error}");
234 process::exit(1);
235 }
236 }
237}
238
239pub(crate) fn check_package_impl(
240 anchor: Option<&Path>,
241) -> Result<PackageCheckReport, PackageError> {
242 let ctx = load_manifest_context_for_anchor(anchor)?;
243 let manifest_path = ctx.manifest_path();
244 let mut errors = Vec::new();
245 let mut warnings = Vec::new();
246
247 let package = ctx.manifest.package.as_ref();
248 let name = package.and_then(|package| package.name.clone());
249 let version = package.and_then(|package| package.version.clone());
250 let package_name = required_package_string(
251 package.and_then(|package| package.name.as_deref()),
252 "[package].name",
253 &mut errors,
254 );
255 if let Some(name) = package_name {
256 if let Err(message) = validate_package_alias(name) {
257 push_error(&mut errors, "[package].name", message);
258 }
259 }
260 required_package_string(
261 package.and_then(|package| package.version.as_deref()),
262 "[package].version",
263 &mut errors,
264 );
265 required_package_string(
266 package.and_then(|package| package.description.as_deref()),
267 "[package].description",
268 &mut errors,
269 );
270 required_package_string(
271 package.and_then(|package| package.license.as_deref()),
272 "[package].license",
273 &mut errors,
274 );
275 if !ctx.dir.join("README.md").is_file() {
276 push_error(&mut errors, "README.md", "package README.md is required");
277 }
278 if !ctx.dir.join("LICENSE").is_file() && package.and_then(|p| p.license.as_deref()).is_none() {
279 push_error(
280 &mut errors,
281 "[package].license",
282 "publishable packages require a license field or LICENSE file",
283 );
284 }
285
286 validate_optional_url(
287 package.and_then(|package| package.repository.as_deref()),
288 "[package].repository",
289 &mut errors,
290 );
291 validate_docs_url(
292 &ctx.dir,
293 package.and_then(|package| package.docs_url.as_deref()),
294 &mut errors,
295 &mut warnings,
296 );
297 match package.and_then(|package| package.harn.as_deref()) {
298 Some(range) if supports_current_harn(range) => {}
299 Some(range) => push_error(
300 &mut errors,
301 "[package].harn",
302 format!(
303 "unsupported Harn version range '{range}'; include the current {} line, for example {}",
304 current_harn_line_label(),
305 current_harn_range_example()
306 ),
307 ),
308 None => push_error(
309 &mut errors,
310 "[package].harn",
311 format!(
312 "missing Harn compatibility metadata; add harn = \"{}\"",
313 current_harn_range_example()
314 ),
315 ),
316 }
317
318 validate_dependencies_for_publish(&ctx, &mut errors, &mut warnings);
319 if let Err(error) = validate_handoff_routes(&ctx.manifest.handoff_routes, &ctx.manifest) {
320 push_error(&mut errors, "handoff_routes", error.to_string());
321 }
322 let exports = validate_exports_for_publish(&ctx, &mut errors, &mut warnings);
323 let (tools, skills) = validate_package_interface_exports(&ctx, &mut errors, &mut warnings);
324
325 Ok(PackageCheckReport {
326 package_dir: ctx.dir.display().to_string(),
327 manifest_path: manifest_path.display().to_string(),
328 name,
329 version,
330 errors,
331 warnings,
332 exports,
333 tools,
334 skills,
335 })
336}
337
338pub(crate) fn list_packages_impl() -> Result<PackageListReport, PackageError> {
339 let workspace = PackageWorkspace::from_current_dir()?;
340 list_packages_in(&workspace)
341}
342
343fn list_packages_in(workspace: &PackageWorkspace) -> Result<PackageListReport, PackageError> {
344 let ctx = workspace.load_manifest_context()?;
345 let lock_path = ctx.lock_path();
346 let lock = LockFile::load(&lock_path)?;
347 let packages = lock
348 .as_ref()
349 .map(|lock| package_list_entries(&ctx, lock))
350 .unwrap_or_default();
351 Ok(PackageListReport {
352 manifest_path: ctx.manifest_path().display().to_string(),
353 lock_path: lock_path.display().to_string(),
354 lock_present: lock.is_some(),
355 dependency_count: ctx.manifest.dependencies.len(),
356 packages,
357 })
358}
359
360pub(crate) fn doctor_packages_impl() -> Result<PackageDoctorReport, PackageError> {
361 let workspace = PackageWorkspace::from_current_dir()?;
362 doctor_packages_in(&workspace)
363}
364
365fn doctor_packages_in(workspace: &PackageWorkspace) -> Result<PackageDoctorReport, PackageError> {
366 let ctx = workspace.load_manifest_context()?;
367 let lock_path = ctx.lock_path();
368 let mut diagnostics = Vec::new();
369
370 let mut root_errors = Vec::new();
371 let mut root_warnings = Vec::new();
372 if let Some(package) = ctx.manifest.package.as_ref() {
373 if let Some(name) = package.name.as_ref() {
374 if let Err(message) = validate_package_alias(name) {
375 push_error(&mut root_errors, "[package].name", message);
376 }
377 }
378 }
379 validate_package_interface_exports(&ctx, &mut root_errors, &mut root_warnings);
380 for diagnostic in root_errors {
381 diagnostics.push(package_doctor_diagnostic(
382 "error",
383 "root-package-contract",
384 format!("{}: {}", diagnostic.field, diagnostic.message),
385 Some("fix install-facing package metadata in harn.toml"),
386 ));
387 }
388 for diagnostic in root_warnings {
389 diagnostics.push(package_doctor_diagnostic(
390 "warning",
391 "root-package-contract",
392 format!("{}: {}", diagnostic.field, diagnostic.message),
393 None::<String>,
394 ));
395 }
396
397 let lock = LockFile::load(&lock_path)?;
398 if ctx.manifest.dependencies.is_empty() {
399 diagnostics.push(package_doctor_diagnostic(
400 "info",
401 "no-dependencies",
402 "manifest has no package dependencies",
403 None::<String>,
404 ));
405 } else if lock.is_none() {
406 diagnostics.push(package_doctor_diagnostic(
407 "error",
408 "missing-lockfile",
409 format!("{} is missing", lock_path.display()),
410 Some("run `harn install` to resolve dependencies and write harn.lock"),
411 ));
412 }
413
414 if let Some(lock) = lock.as_ref() {
415 if let Err(error) = validate_lock_matches_manifest(&ctx, lock) {
416 diagnostics.push(package_doctor_diagnostic(
417 "error",
418 "stale-lockfile",
419 error.to_string(),
420 Some("run `harn install` to refresh harn.lock"),
421 ));
422 }
423 for entry in &lock.packages {
424 validate_installed_package_entry(&ctx, entry, &mut diagnostics);
425 }
426 }
427
428 let packages = lock
429 .as_ref()
430 .map(|lock| package_list_entries(&ctx, lock))
431 .unwrap_or_default();
432 let ok = diagnostics
433 .iter()
434 .all(|diagnostic| diagnostic.severity != "error");
435 Ok(PackageDoctorReport {
436 ok,
437 manifest_path: ctx.manifest_path().display().to_string(),
438 lock_path: lock_path.display().to_string(),
439 diagnostics,
440 packages,
441 })
442}
443
444fn package_list_entries(ctx: &ManifestContext, lock: &LockFile) -> Vec<PackageListEntry> {
445 lock.packages
446 .iter()
447 .map(|entry| {
448 let materialized = materialized_package_exists(ctx, entry);
449 PackageListEntry {
450 name: entry.name.clone(),
451 source: entry.source.clone(),
452 package_version: entry.package_version.clone(),
453 harn_compat: entry.harn_compat.clone(),
454 provenance: entry.provenance.clone(),
455 materialized,
456 integrity: package_integrity_status(ctx, entry),
457 exports: entry.exports.clone(),
458 permissions: entry.permissions.clone(),
459 host_requirements: entry.host_requirements.clone(),
460 }
461 })
462 .collect()
463}
464
465fn materialized_package_path(ctx: &ManifestContext, entry: &LockEntry) -> PathBuf {
466 let packages_dir = ctx.packages_dir();
467 let dir = packages_dir.join(&entry.name);
468 if dir.exists() {
469 return dir;
470 }
471 packages_dir.join(format!("{}.harn", entry.name))
472}
473
474fn materialized_package_exists(ctx: &ManifestContext, entry: &LockEntry) -> bool {
475 materialized_package_path(ctx, entry).exists()
476}
477
478fn package_integrity_status(ctx: &ManifestContext, entry: &LockEntry) -> String {
479 if !materialized_package_exists(ctx, entry) {
480 return "missing".to_string();
481 }
482 let Some(expected) = entry.content_hash.as_deref() else {
483 return "not_checked".to_string();
484 };
485 let path = materialized_package_path(ctx, entry);
486 if path.is_dir() && materialized_hash_matches(&path, expected) {
487 "ok".to_string()
488 } else {
489 "mismatch".to_string()
490 }
491}
492
493fn validate_installed_package_entry(
494 ctx: &ManifestContext,
495 entry: &LockEntry,
496 diagnostics: &mut Vec<PackageDoctorDiagnostic>,
497) {
498 let materialized_path = materialized_package_path(ctx, entry);
499 if !materialized_path.exists() {
500 diagnostics.push(package_doctor_diagnostic(
501 "error",
502 "package-not-materialized",
503 format!(
504 "package {} is locked but missing from {}",
505 entry.name,
506 ctx.packages_dir().display()
507 ),
508 Some("run `harn install` to materialize locked packages"),
509 ));
510 return;
511 }
512
513 if package_integrity_status(ctx, entry) == "mismatch" {
514 diagnostics.push(package_doctor_diagnostic(
515 "error",
516 "content-hash-mismatch",
517 format!(
518 "package {} does not match its locked content hash",
519 entry.name
520 ),
521 Some(
522 "run `harn install --refetch {alias}` or inspect local tampering"
523 .replace("{alias}", &entry.name),
524 ),
525 ));
526 }
527
528 for requirement in &entry.host_requirements {
529 if !host_requirement_satisfied(&ctx.manifest.check, requirement) {
530 diagnostics.push(package_doctor_diagnostic(
531 "error",
532 "missing-host-capability",
533 format!(
534 "package {} requires host capability {requirement}, but harn.toml does not declare it",
535 entry.name
536 ),
537 Some("add the capability under [check.host_capabilities] or preflight_allow after the host implements it"),
538 ));
539 }
540 }
541
542 if materialized_path.is_dir() {
543 match read_package_manifest_from_dir(&materialized_path) {
544 Ok(Some(manifest)) => {
545 let installed_ctx = ManifestContext {
546 manifest,
547 dir: materialized_path,
548 };
549 let mut errors = Vec::new();
550 let mut warnings = Vec::new();
551 validate_package_interface_exports(&installed_ctx, &mut errors, &mut warnings);
552 for diagnostic in errors {
553 diagnostics.push(package_doctor_diagnostic(
554 "error",
555 "installed-package-export",
556 format!("{}: {}", diagnostic.field, diagnostic.message),
557 Some(format!("fix package {} and reinstall it", entry.name)),
558 ));
559 }
560 for diagnostic in warnings {
561 diagnostics.push(package_doctor_diagnostic(
562 "warning",
563 "installed-package-export-warning",
564 format!("{}: {}", diagnostic.field, diagnostic.message),
565 None::<String>,
566 ));
567 }
568 }
569 Ok(None) => {}
570 Err(error) => diagnostics.push(package_doctor_diagnostic(
571 "error",
572 "installed-manifest-unreadable",
573 format!("failed to read package {} manifest: {error}", entry.name),
574 Some("repair the package source and run `harn install`"),
575 )),
576 }
577 }
578}
579
580fn host_requirement_satisfied(check: &CheckConfig, requirement: &str) -> bool {
581 if check.preflight_allow.iter().any(|allow| {
582 allow == "*"
583 || allow == requirement
584 || requirement
585 .strip_prefix(allow.trim_end_matches(".*"))
586 .is_some_and(|rest| allow.ends_with(".*") && rest.starts_with('.'))
587 || requirement
588 .split_once('.')
589 .is_some_and(|(capability, _)| allow == capability)
590 }) {
591 return true;
592 }
593 let Some((capability, operation)) = requirement.split_once('.') else {
594 return false;
595 };
596 check
597 .host_capabilities
598 .get(capability)
599 .is_some_and(|ops| ops.iter().any(|op| op == "*" || op == operation))
600}
601
602fn package_doctor_diagnostic(
603 severity: impl Into<String>,
604 code: impl Into<String>,
605 message: impl Into<String>,
606 help: Option<impl Into<String>>,
607) -> PackageDoctorDiagnostic {
608 PackageDoctorDiagnostic {
609 severity: severity.into(),
610 code: code.into(),
611 message: message.into(),
612 help: help.map(Into::into),
613 }
614}
615
616pub(crate) fn pack_package_impl(
617 anchor: Option<&Path>,
618 output: Option<&Path>,
619 dry_run: bool,
620) -> Result<PackagePackReport, PackageError> {
621 let report = check_package_impl(anchor)?;
622 fail_if_package_errors(&report)?;
623 let ctx = load_manifest_context_for_anchor(anchor)?;
624 let files = collect_package_files(&ctx.dir)?;
625 let artifact_dir = output
626 .map(Path::to_path_buf)
627 .unwrap_or_else(|| default_artifact_dir(&ctx, &report));
628
629 if !dry_run {
630 if artifact_dir.exists() {
631 return Err(
632 format!("artifact output {} already exists", artifact_dir.display()).into(),
633 );
634 }
635 fs::create_dir_all(&artifact_dir)
636 .map_err(|error| format!("failed to create {}: {error}", artifact_dir.display()))?;
637 for rel in &files {
638 let src = ctx.dir.join(rel);
639 let dst = artifact_dir.join(rel);
640 if let Some(parent) = dst.parent() {
641 fs::create_dir_all(parent)
642 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
643 }
644 fs::copy(&src, &dst)
645 .map_err(|error| format!("failed to copy {}: {error}", src.display()))?;
646 }
647 let manifest_path = artifact_dir.join(".harn-package-manifest.json");
648 let manifest_body = serde_json::to_string_pretty(&report)
649 .map_err(|error| format!("failed to render package manifest: {error}"))?
650 + "\n";
651 harn_vm::atomic_io::atomic_write(&manifest_path, manifest_body.as_bytes())
652 .map_err(|error| format!("failed to write {}: {error}", manifest_path.display()))?;
653 }
654
655 Ok(PackagePackReport {
656 package_dir: ctx.dir.display().to_string(),
657 artifact_dir: artifact_dir.display().to_string(),
658 dry_run,
659 files,
660 check: report,
661 })
662}
663
664pub(crate) fn generate_package_docs_impl(
665 anchor: Option<&Path>,
666 output: Option<&Path>,
667 check: bool,
668) -> Result<PathBuf, PackageError> {
669 let report = check_package_impl(anchor)?;
670 let ctx = load_manifest_context_for_anchor(anchor)?;
671 let output_path = output
672 .map(Path::to_path_buf)
673 .unwrap_or_else(|| ctx.dir.join("docs").join("api.md"));
674 let rendered = render_package_api_docs(&report);
675 if check {
676 let existing = fs::read_to_string(&output_path)
677 .map_err(|error| format!("failed to read {}: {error}", output_path.display()))?;
678 if normalize_newlines(&existing) != normalize_newlines(&rendered) {
679 return Err(format!(
680 "{} is stale; run `harn package docs`",
681 output_path.display()
682 )
683 .into());
684 }
685 return Ok(output_path);
686 }
687 harn_vm::atomic_io::atomic_write(&output_path, rendered.as_bytes())
688 .map_err(|error| format!("failed to write {}: {error}", output_path.display()))?;
689 Ok(output_path)
690}
691
692pub(crate) fn publish_package_impl(
693 anchor: Option<&Path>,
694 registry: Option<&str>,
695) -> Result<PackagePublishReport, PackageError> {
696 let pack = pack_package_impl(anchor, None, true)?;
697 let registry = resolve_configured_registry_source(registry)?;
698 Ok(PackagePublishReport {
699 dry_run: true,
700 registry,
701 artifact_dir: pack.artifact_dir,
702 files: pack.files,
703 check: pack.check,
704 })
705}
706
707pub(crate) fn load_manifest_context_for_anchor(
708 anchor: Option<&Path>,
709) -> Result<ManifestContext, PackageError> {
710 let anchor = anchor
711 .map(Path::to_path_buf)
712 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
713 let manifest_path = if anchor.is_dir() {
714 anchor.join(MANIFEST)
715 } else if anchor.file_name() == Some(OsStr::new(MANIFEST)) {
716 anchor.clone()
717 } else {
718 let (_, dir) = find_nearest_manifest(&anchor)
719 .ok_or_else(|| format!("no {MANIFEST} found from {}", anchor.display()))?;
720 dir.join(MANIFEST)
721 };
722 let manifest = read_manifest_from_path(&manifest_path)?;
723 let dir = manifest_path
724 .parent()
725 .map(Path::to_path_buf)
726 .unwrap_or_else(|| PathBuf::from("."));
727 Ok(ManifestContext { manifest, dir })
728}
729
730pub(crate) fn required_package_string<'a>(
731 value: Option<&'a str>,
732 field: &str,
733 errors: &mut Vec<PackageCheckDiagnostic>,
734) -> Option<&'a str> {
735 match value.map(str::trim).filter(|value| !value.is_empty()) {
736 Some(value) => Some(value),
737 None => {
738 push_error(errors, field, format!("missing required {field}"));
739 None
740 }
741 }
742}
743
744pub(crate) fn push_error(
745 diagnostics: &mut Vec<PackageCheckDiagnostic>,
746 field: impl Into<String>,
747 message: impl Into<String>,
748) {
749 diagnostics.push(PackageCheckDiagnostic {
750 field: field.into(),
751 message: message.into(),
752 });
753}
754
755pub(crate) fn push_warning(
756 diagnostics: &mut Vec<PackageCheckDiagnostic>,
757 field: impl Into<String>,
758 message: impl Into<String>,
759) {
760 push_error(diagnostics, field, message);
761}
762
763pub(crate) fn validate_optional_url(
764 value: Option<&str>,
765 field: &str,
766 errors: &mut Vec<PackageCheckDiagnostic>,
767) {
768 let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
769 push_error(errors, field, format!("missing required {field}"));
770 return;
771 };
772 if Url::parse(value).is_err() {
773 push_error(errors, field, format!("{field} must be an absolute URL"));
774 }
775}
776
777pub(crate) fn validate_docs_url(
778 root: &Path,
779 value: Option<&str>,
780 errors: &mut Vec<PackageCheckDiagnostic>,
781 warnings: &mut Vec<PackageCheckDiagnostic>,
782) {
783 let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
784 push_warning(
785 warnings,
786 "[package].docs_url",
787 "missing docs_url; `harn package docs` defaults to docs/api.md",
788 );
789 return;
790 };
791 if Url::parse(value).is_ok() {
792 return;
793 }
794 let path = PathBuf::from(value);
795 let path = if path.is_absolute() {
796 path
797 } else {
798 root.join(path)
799 };
800 if !path.exists() {
801 push_error(
802 errors,
803 "[package].docs_url",
804 format!("docs_url path {} does not exist", path.display()),
805 );
806 }
807}
808
809pub(crate) fn validate_dependencies_for_publish(
810 ctx: &ManifestContext,
811 errors: &mut Vec<PackageCheckDiagnostic>,
812 warnings: &mut Vec<PackageCheckDiagnostic>,
813) {
814 let mut aliases = BTreeSet::new();
815 for (alias, dependency) in &ctx.manifest.dependencies {
816 let field = format!("[dependencies].{alias}");
817 if let Err(message) = validate_package_alias(alias) {
818 push_error(errors, &field, message);
819 }
820 if !aliases.insert(alias) {
821 push_error(errors, &field, "duplicate dependency alias");
822 }
823 match dependency {
824 Dependency::Path(path) => push_error(
825 errors,
826 &field,
827 format!("path-only dependency '{path}' is not publishable; pin a git rev or registry version"),
828 ),
829 Dependency::Table(table) => {
830 if table.path.is_some() {
831 push_error(
832 errors,
833 &field,
834 "path dependencies are not publishable; pin a git rev or registry version",
835 );
836 }
837 if table.git.is_none() && table.path.is_none() {
838 push_error(errors, &field, "dependency must specify git, registry-expanded git, or path");
839 }
840 if table.rev.is_some() && table.branch.is_some() {
841 push_error(errors, &field, "dependency cannot specify both rev and branch");
842 }
843 if table.git.is_some() && table.rev.is_none() && table.branch.is_none() {
844 push_error(errors, &field, "git dependency must specify rev or branch");
845 }
846 if table.branch.is_some() {
847 push_warning(
848 warnings,
849 &field,
850 "branch dependencies are allowed but rev pins are more reproducible for publishing",
851 );
852 }
853 if let Some(git) = table.git.as_deref() {
854 if normalize_git_url(git).is_err() {
855 push_error(errors, &field, format!("invalid git source '{git}'"));
856 }
857 }
858 }
859 }
860 }
861}
862
863pub(crate) fn validate_exports_for_publish(
864 ctx: &ManifestContext,
865 errors: &mut Vec<PackageCheckDiagnostic>,
866 warnings: &mut Vec<PackageCheckDiagnostic>,
867) -> Vec<PackageExportReport> {
868 if ctx.manifest.exports.is_empty() {
869 push_error(
870 errors,
871 "[exports]",
872 "publishable packages require at least one stable export",
873 );
874 return Vec::new();
875 }
876
877 let mut exports = Vec::new();
878 for (name, rel_path) in &ctx.manifest.exports {
879 let field = format!("[exports].{name}");
880 if let Err(message) = validate_package_alias(name) {
881 push_error(errors, &field, message);
882 }
883 let Ok(path) = safe_package_relative_path(&ctx.dir, rel_path) else {
884 push_error(
885 errors,
886 &field,
887 "export path must stay inside the package directory",
888 );
889 continue;
890 };
891 if path.extension() != Some(OsStr::new("harn")) {
892 push_error(errors, &field, "export path must point at a .harn file");
893 continue;
894 }
895 let content = match fs::read_to_string(&path) {
896 Ok(content) => content,
897 Err(error) => {
898 push_error(
899 errors,
900 &field,
901 format!("failed to read export {}: {error}", path.display()),
902 );
903 continue;
904 }
905 };
906 if let Err(error) = parse_harn_source(&content) {
907 push_error(errors, &field, format!("failed to parse export: {error}"));
908 }
909 let symbols = extract_api_symbols(&content);
910 if symbols.is_empty() {
911 push_warning(
912 warnings,
913 &field,
914 "exported module has no public symbols to document",
915 );
916 }
917 for symbol in &symbols {
918 if symbol.docs.is_none() {
919 push_warning(
920 warnings,
921 &field,
922 format!(
923 "public {} '{}' has no doc comment",
924 symbol.kind, symbol.name
925 ),
926 );
927 }
928 }
929 exports.push(PackageExportReport {
930 name: name.clone(),
931 path: rel_path.clone(),
932 symbols,
933 });
934 }
935 exports.sort_by(|left, right| left.name.cmp(&right.name));
936 exports
937}
938
939pub(crate) fn validate_package_interface_exports(
940 ctx: &ManifestContext,
941 errors: &mut Vec<PackageCheckDiagnostic>,
942 warnings: &mut Vec<PackageCheckDiagnostic>,
943) -> (Vec<PackageToolExportReport>, Vec<PackageSkillExportReport>) {
944 let Some(package) = ctx.manifest.package.as_ref() else {
945 return (Vec::new(), Vec::new());
946 };
947
948 validate_permission_tokens(
949 &package.permissions,
950 "[package].permissions",
951 errors,
952 warnings,
953 );
954 validate_host_requirements(
955 &package.host_requirements,
956 "[package].host_requirements",
957 errors,
958 );
959
960 let mut tools = Vec::new();
961 for (index, tool) in package.tools.iter().enumerate() {
962 let field = format!("[[package.tools]] #{}", index + 1);
963 if let Err(message) = validate_package_alias(&tool.name) {
964 push_error(errors, format!("{field}.name"), message.to_string());
965 }
966 validate_required_manifest_string(&tool.module, &format!("{field}.module"), errors);
967 validate_required_manifest_string(&tool.symbol, &format!("{field}.symbol"), errors);
968 validate_package_module_path(ctx, &tool.module, &format!("{field}.module"), errors);
969 validate_permission_tokens(
970 &tool.permissions,
971 &format!("{field}.permissions"),
972 errors,
973 warnings,
974 );
975 validate_host_requirements(
976 &tool.host_requirements,
977 &format!("{field}.host_requirements"),
978 errors,
979 );
980 validate_schema_value(
981 tool.input_schema.as_ref(),
982 &format!("{field}.input_schema"),
983 errors,
984 );
985 validate_schema_value(
986 tool.output_schema.as_ref(),
987 &format!("{field}.output_schema"),
988 errors,
989 );
990 validate_tool_annotations(&tool.annotations, &format!("{field}.annotations"), errors);
991 if tool.annotations.is_empty() {
992 push_warning(
993 warnings,
994 format!("{field}.annotations"),
995 "tool export has no annotations; policy evaluation will treat it conservatively",
996 );
997 }
998 tools.push(PackageToolExportReport {
999 name: tool.name.clone(),
1000 module: tool.module.clone(),
1001 symbol: tool.symbol.clone(),
1002 permissions: merge_package_requirements(&package.permissions, &tool.permissions),
1003 host_requirements: merge_package_requirements(
1004 &package.host_requirements,
1005 &tool.host_requirements,
1006 ),
1007 });
1008 }
1009 tools.sort_by(|left, right| left.name.cmp(&right.name));
1010
1011 let mut skills = Vec::new();
1012 for (index, skill) in package.skills.iter().enumerate() {
1013 let field = format!("[[package.skills]] #{}", index + 1);
1014 if let Err(message) = validate_package_alias(&skill.name) {
1015 push_error(errors, format!("{field}.name"), message.to_string());
1016 }
1017 validate_required_manifest_string(&skill.path, &format!("{field}.path"), errors);
1018 validate_package_skill_path(ctx, &skill.path, &format!("{field}.path"), errors);
1019 validate_permission_tokens(
1020 &skill.permissions,
1021 &format!("{field}.permissions"),
1022 errors,
1023 warnings,
1024 );
1025 validate_host_requirements(
1026 &skill.host_requirements,
1027 &format!("{field}.host_requirements"),
1028 errors,
1029 );
1030 skills.push(PackageSkillExportReport {
1031 name: skill.name.clone(),
1032 path: skill.path.clone(),
1033 permissions: merge_package_requirements(&package.permissions, &skill.permissions),
1034 host_requirements: merge_package_requirements(
1035 &package.host_requirements,
1036 &skill.host_requirements,
1037 ),
1038 });
1039 }
1040 skills.sort_by(|left, right| left.name.cmp(&right.name));
1041
1042 (tools, skills)
1043}
1044
1045pub(crate) fn merge_package_requirements(base: &[String], item: &[String]) -> Vec<String> {
1046 let mut merged = BTreeSet::new();
1047 merged.extend(
1048 base.iter()
1049 .filter_map(|value| normalized_requirement(value)),
1050 );
1051 merged.extend(
1052 item.iter()
1053 .filter_map(|value| normalized_requirement(value)),
1054 );
1055 merged.into_iter().collect()
1056}
1057
1058fn normalized_requirement(value: &str) -> Option<String> {
1059 let trimmed = value.trim();
1060 (!trimmed.is_empty()).then(|| trimmed.to_string())
1061}
1062
1063fn validate_required_manifest_string(
1064 value: &str,
1065 field: &str,
1066 errors: &mut Vec<PackageCheckDiagnostic>,
1067) {
1068 if value.trim().is_empty() {
1069 push_error(errors, field, format!("missing required {field}"));
1070 }
1071}
1072
1073fn validate_permission_tokens(
1074 permissions: &[String],
1075 field: &str,
1076 errors: &mut Vec<PackageCheckDiagnostic>,
1077 warnings: &mut Vec<PackageCheckDiagnostic>,
1078) {
1079 let mut seen = BTreeSet::new();
1080 for permission in permissions {
1081 let trimmed = permission.trim();
1082 if trimmed.is_empty() {
1083 push_error(errors, field, "permission entries cannot be empty");
1084 continue;
1085 }
1086 if trimmed.chars().any(char::is_whitespace) {
1087 push_error(
1088 errors,
1089 field,
1090 format!("permission {permission:?} cannot contain whitespace"),
1091 );
1092 }
1093 if !trimmed.contains(':') && !trimmed.contains('.') {
1094 push_warning(
1095 warnings,
1096 field,
1097 format!("permission {permission:?} should use a namespaced token"),
1098 );
1099 }
1100 if !seen.insert(trimmed.to_string()) {
1101 push_warning(
1102 warnings,
1103 field,
1104 format!("duplicate permission {permission:?}"),
1105 );
1106 }
1107 }
1108}
1109
1110pub(crate) fn validate_host_requirements(
1111 requirements: &[String],
1112 field: &str,
1113 errors: &mut Vec<PackageCheckDiagnostic>,
1114) {
1115 let mut seen = BTreeSet::new();
1116 for requirement in requirements {
1117 let trimmed = requirement.trim();
1118 if trimmed.is_empty() {
1119 push_error(errors, field, "host requirement entries cannot be empty");
1120 continue;
1121 }
1122 let Some((capability, operation)) = trimmed.split_once('.') else {
1123 push_error(
1124 errors,
1125 field,
1126 format!("host requirement {requirement:?} must use capability.operation"),
1127 );
1128 continue;
1129 };
1130 if !valid_identifier(capability)
1131 || !(valid_identifier(operation) || operation == "*")
1132 || trimmed.matches('.').count() != 1
1133 {
1134 push_error(
1135 errors,
1136 field,
1137 format!("host requirement {requirement:?} must use valid capability.operation identifiers"),
1138 );
1139 }
1140 if !seen.insert(trimmed.to_string()) {
1141 push_error(
1142 errors,
1143 field,
1144 format!("duplicate host requirement {requirement:?}"),
1145 );
1146 }
1147 }
1148}
1149
1150fn validate_package_module_path(
1151 ctx: &ManifestContext,
1152 rel_path: &str,
1153 field: &str,
1154 errors: &mut Vec<PackageCheckDiagnostic>,
1155) {
1156 let Ok(path) = safe_package_relative_path(&ctx.dir, rel_path) else {
1157 push_error(errors, field, "module path must stay inside the package");
1158 return;
1159 };
1160 if path.extension() != Some(OsStr::new("harn")) {
1161 push_error(errors, field, "module path must point at a .harn file");
1162 return;
1163 }
1164 match fs::read_to_string(&path) {
1165 Ok(content) => {
1166 if let Err(error) = parse_harn_source(&content) {
1167 push_error(errors, field, format!("failed to parse module: {error}"));
1168 }
1169 }
1170 Err(error) => push_error(
1171 errors,
1172 field,
1173 format!("failed to read module {}: {error}", path.display()),
1174 ),
1175 }
1176}
1177
1178fn validate_package_skill_path(
1179 ctx: &ManifestContext,
1180 rel_path: &str,
1181 field: &str,
1182 errors: &mut Vec<PackageCheckDiagnostic>,
1183) {
1184 let Ok(path) = safe_package_relative_path(&ctx.dir, rel_path) else {
1185 push_error(errors, field, "skill path must stay inside the package");
1186 return;
1187 };
1188 let skill_file = if path.is_dir() {
1189 path.join("SKILL.md")
1190 } else {
1191 path.clone()
1192 };
1193 if skill_file.file_name() != Some(OsStr::new("SKILL.md")) {
1194 push_error(
1195 errors,
1196 field,
1197 "skill path must be a SKILL.md file or skill directory",
1198 );
1199 return;
1200 }
1201 match fs::read_to_string(&skill_file) {
1202 Ok(content) => {
1203 let (frontmatter, _) = harn_vm::skills::split_frontmatter(&content);
1204 if let Err(error) = harn_vm::skills::parse_frontmatter(frontmatter) {
1205 push_error(
1206 errors,
1207 field,
1208 format!("invalid SKILL.md frontmatter: {error}"),
1209 );
1210 }
1211 }
1212 Err(error) => push_error(
1213 errors,
1214 field,
1215 format!("failed to read skill {}: {error}", skill_file.display()),
1216 ),
1217 }
1218}
1219
1220fn validate_schema_value(
1221 value: Option<&toml::Value>,
1222 field: &str,
1223 errors: &mut Vec<PackageCheckDiagnostic>,
1224) {
1225 let Some(value) = value else {
1226 return;
1227 };
1228 let json = match toml_value_to_json(value) {
1229 Ok(json) => json,
1230 Err(error) => {
1231 push_error(errors, field, error);
1232 return;
1233 }
1234 };
1235 let Some(object) = json.as_object() else {
1236 push_error(errors, field, "schema must be a table/object");
1237 return;
1238 };
1239 if let Some(schema_type) = object.get("type") {
1240 if !schema_type.is_string() {
1241 push_error(errors, field, "schema `type` must be a string when present");
1242 }
1243 }
1244 if let Some(required) = object.get("required") {
1245 let valid = required
1246 .as_array()
1247 .is_some_and(|items| items.iter().all(|item| item.as_str().is_some()));
1248 if !valid {
1249 push_error(errors, field, "schema `required` must be a list of strings");
1250 }
1251 }
1252}
1253
1254fn validate_tool_annotations(
1255 annotations: &BTreeMap<String, toml::Value>,
1256 field: &str,
1257 errors: &mut Vec<PackageCheckDiagnostic>,
1258) {
1259 if annotations.is_empty() {
1260 return;
1261 }
1262 let json = match toml_value_to_json(&toml::Value::Table(
1263 annotations
1264 .clone()
1265 .into_iter()
1266 .collect::<toml::map::Map<String, toml::Value>>(),
1267 )) {
1268 Ok(json) => json,
1269 Err(error) => {
1270 push_error(errors, field, error);
1271 return;
1272 }
1273 };
1274 if let Err(error) = serde_json::from_value::<harn_vm::tool_annotations::ToolAnnotations>(json) {
1275 push_error(
1276 errors,
1277 field,
1278 format!("annotations do not match ToolAnnotations: {error}"),
1279 );
1280 }
1281}
1282
1283fn toml_value_to_json(value: &toml::Value) -> Result<serde_json::Value, String> {
1284 serde_json::to_value(value).map_err(|error| format!("failed to normalize TOML value: {error}"))
1285}
1286
1287pub(crate) fn parse_harn_source(source: &str) -> Result<(), PackageError> {
1288 let mut lexer = harn_lexer::Lexer::new(source);
1289 let tokens = lexer.tokenize().map_err(|error| error.to_string())?;
1290 let mut parser = harn_parser::Parser::new(tokens);
1291 parser
1292 .parse()
1293 .map(|_| ())
1294 .map_err(|error| PackageError::Ops(error.to_string()))
1295}
1296
1297pub(crate) fn safe_package_relative_path(
1298 root: &Path,
1299 rel_path: &str,
1300) -> Result<PathBuf, PackageError> {
1301 let rel = PathBuf::from(rel_path);
1302 if rel.is_absolute()
1303 || has_windows_rooted_or_drive_relative_prefix(rel_path)
1304 || rel.components().any(|component| {
1305 matches!(
1306 component,
1307 std::path::Component::ParentDir
1308 | std::path::Component::Prefix(_)
1309 | std::path::Component::RootDir
1310 )
1311 })
1312 {
1313 return Err(format!("path {rel_path:?} escapes package root").into());
1314 }
1315 Ok(root.join(rel))
1316}
1317
1318fn has_windows_rooted_or_drive_relative_prefix(path: &str) -> bool {
1319 let normalized = path.replace('\\', "/");
1320 let bytes = normalized.as_bytes();
1321 normalized.starts_with('/')
1322 || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
1323}
1324
1325pub(crate) fn extract_api_symbols(source: &str) -> Vec<PackageApiSymbol> {
1326 static DECL_RE: OnceLock<Regex> = OnceLock::new();
1327 let decl_re = DECL_RE.get_or_init(|| {
1328 Regex::new(r"^\s*pub\s+(fn|pipeline|tool|skill|struct|enum|type|interface)\s+([A-Za-z_][A-Za-z0-9_]*)\b(.*)$")
1329 .expect("valid declaration regex")
1330 });
1331 let mut docs: Vec<String> = Vec::new();
1332 let mut symbols = Vec::new();
1333 let mut in_block_doc = false;
1334 for line in source.lines() {
1335 let trimmed = line.trim();
1336 if in_block_doc {
1337 let (content, closes) = match trimmed.split_once("*/") {
1341 Some((before, _)) => (before, true),
1342 None => (trimmed, false),
1343 };
1344 let stripped = content
1345 .strip_prefix("* ")
1346 .or_else(|| content.strip_prefix('*'))
1347 .unwrap_or(content)
1348 .trim();
1349 if !stripped.is_empty() {
1350 docs.push(stripped.to_string());
1351 }
1352 if closes {
1353 in_block_doc = false;
1354 }
1355 continue;
1356 }
1357 if let Some(doc) = trimmed.strip_prefix("///") {
1358 docs.push(doc.trim().to_string());
1359 continue;
1360 }
1361 if let Some(rest) = trimmed.strip_prefix("/**") {
1362 if let Some((inner, _)) = rest.split_once("*/") {
1366 let stripped = inner.trim();
1367 if !stripped.is_empty() {
1368 docs.push(stripped.to_string());
1369 }
1370 } else {
1371 let stripped = rest.trim();
1372 if !stripped.is_empty() {
1373 docs.push(stripped.to_string());
1374 }
1375 in_block_doc = true;
1376 }
1377 continue;
1378 }
1379 if trimmed.is_empty() {
1380 continue;
1381 }
1382 if let Some(captures) = decl_re.captures(line) {
1383 let kind = captures.get(1).expect("kind").as_str().to_string();
1384 let name = captures.get(2).expect("name").as_str().to_string();
1385 let signature = trim_signature(line);
1386 let doc_text = (!docs.is_empty()).then(|| docs.join("\n"));
1387 symbols.push(PackageApiSymbol {
1388 kind,
1389 name,
1390 signature,
1391 docs: doc_text,
1392 });
1393 }
1394 docs.clear();
1395 }
1396 symbols
1397}
1398
1399pub(crate) fn trim_signature(line: &str) -> String {
1400 let mut signature = line.trim().to_string();
1401 if let Some((before, _)) = signature.split_once('{') {
1402 signature = before.trim_end().to_string();
1403 }
1404 signature
1405}
1406
1407pub(crate) fn supports_current_harn(range: &str) -> bool {
1408 let current = env!("CARGO_PKG_VERSION");
1409 let Some((major, minor)) = parse_major_minor(current) else {
1410 return true;
1411 };
1412 let range = range.trim();
1413 if range.is_empty() {
1414 return false;
1415 }
1416 if let Some(rest) = range.strip_prefix('^') {
1417 return parse_major_minor(rest).is_some_and(|(m, n)| m == major && n == minor);
1418 }
1419 if !range.contains([',', '<', '>', '=']) {
1420 return parse_major_minor(range).is_some_and(|(m, n)| m == major && n == minor);
1421 }
1422
1423 let current_value = major * 1000 + minor;
1424 let mut lower_ok = true;
1425 let mut upper_ok = true;
1426 let mut saw_constraint = false;
1427 for raw in range.split(',') {
1428 let part = raw.trim();
1429 if part.is_empty() {
1430 continue;
1431 }
1432 saw_constraint = true;
1433 if let Some(rest) = part.strip_prefix(">=") {
1434 if let Some((m, n)) = parse_major_minor(rest.trim()) {
1435 lower_ok &= current_value >= m * 1000 + n;
1436 } else {
1437 return false;
1438 }
1439 } else if let Some(rest) = part.strip_prefix('>') {
1440 if let Some((m, n)) = parse_major_minor(rest.trim()) {
1441 lower_ok &= current_value > m * 1000 + n;
1442 } else {
1443 return false;
1444 }
1445 } else if let Some(rest) = part.strip_prefix("<=") {
1446 if let Some((m, n)) = parse_major_minor(rest.trim()) {
1447 upper_ok &= current_value <= m * 1000 + n;
1448 } else {
1449 return false;
1450 }
1451 } else if let Some(rest) = part.strip_prefix('<') {
1452 if let Some((m, n)) = parse_major_minor(rest.trim()) {
1453 upper_ok &= current_value < m * 1000 + n;
1454 } else {
1455 return false;
1456 }
1457 } else if let Some(rest) = part.strip_prefix('=') {
1458 if let Some((m, n)) = parse_major_minor(rest.trim()) {
1459 lower_ok &= current_value == m * 1000 + n;
1460 upper_ok &= current_value == m * 1000 + n;
1461 } else {
1462 return false;
1463 }
1464 } else {
1465 return false;
1466 }
1467 }
1468 saw_constraint && lower_ok && upper_ok
1469}
1470
1471pub(crate) fn current_harn_range_example() -> String {
1472 let current = env!("CARGO_PKG_VERSION");
1473 let Some((major, minor)) = parse_major_minor(current) else {
1474 return ">=0.7,<0.8".to_string();
1475 };
1476 format!(">={major}.{minor},<{major}.{}", minor + 1)
1477}
1478
1479pub(crate) fn current_harn_line_label() -> String {
1480 let current = env!("CARGO_PKG_VERSION");
1481 let Some((major, minor)) = parse_major_minor(current) else {
1482 return "0.7".to_string();
1483 };
1484 format!("{major}.{minor}")
1485}
1486
1487pub(crate) fn parse_major_minor(raw: &str) -> Option<(u64, u64)> {
1488 let raw = raw.trim().trim_start_matches('v');
1489 let mut parts = raw.split('.');
1490 let major = parts.next()?.parse().ok()?;
1491 let minor = parts.next()?.trim_end_matches('x').parse().ok()?;
1492 Some((major, minor))
1493}
1494
1495pub(crate) fn collect_package_files(root: &Path) -> Result<Vec<String>, PackageError> {
1496 let mut files = Vec::new();
1497 collect_package_files_inner(root, root, &mut files)?;
1498 files.sort();
1499 Ok(files)
1500}
1501
1502pub(crate) fn collect_package_files_inner(
1503 root: &Path,
1504 dir: &Path,
1505 out: &mut Vec<String>,
1506) -> Result<(), PackageError> {
1507 for entry in
1508 fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?
1509 {
1510 let entry =
1511 entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?;
1512 let path = entry.path();
1513 let file_type = entry
1514 .file_type()
1515 .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?;
1516 if file_type.is_symlink() {
1517 continue;
1518 }
1519 if file_type.is_dir() {
1520 let rel = path
1521 .strip_prefix(root)
1522 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
1523 if should_skip_package_dir(rel) {
1524 continue;
1525 }
1526 collect_package_files_inner(root, &path, out)?;
1527 } else if file_type.is_file() {
1528 let rel = path
1529 .strip_prefix(root)
1530 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?
1531 .to_string_lossy()
1532 .replace('\\', "/");
1533 out.push(rel);
1534 }
1535 }
1536 Ok(())
1537}
1538
1539pub(crate) fn should_skip_package_dir(rel: &Path) -> bool {
1540 if rel == Path::new("docs").join("dist") {
1541 return true;
1542 }
1543 rel.components().any(|component| {
1544 matches!(
1545 component.as_os_str().to_str(),
1546 Some(".git" | ".harn" | "target" | "node_modules")
1547 )
1548 })
1549}
1550
1551pub(crate) fn default_artifact_dir(ctx: &ManifestContext, report: &PackageCheckReport) -> PathBuf {
1552 let name = report.name.as_deref().unwrap_or("package");
1553 let version = report.version.as_deref().unwrap_or("0.0.0");
1554 ctx.dir
1555 .join(".harn")
1556 .join("dist")
1557 .join(format!("{name}-{version}"))
1558}
1559
1560pub(crate) fn fail_if_package_errors(report: &PackageCheckReport) -> Result<(), PackageError> {
1561 if report.errors.is_empty() {
1562 return Ok(());
1563 }
1564 Err(format!(
1565 "package check failed:\n{}",
1566 report
1567 .errors
1568 .iter()
1569 .map(|diagnostic| format!("- {}: {}", diagnostic.field, diagnostic.message))
1570 .collect::<Vec<_>>()
1571 .join("\n")
1572 )
1573 .into())
1574}
1575
1576pub(crate) fn render_package_api_docs(report: &PackageCheckReport) -> String {
1577 let title = report.name.as_deref().unwrap_or("package");
1578 let mut out = format!("# API Reference: {title}\n\nGenerated by `harn package docs`.\n");
1579 if let Some(version) = report.version.as_deref() {
1580 out.push_str(&format!("\nVersion: `{version}`\n"));
1581 }
1582 for export in &report.exports {
1583 out.push_str(&format!(
1584 "\n## Export `{}`\n\n`{}`\n",
1585 export.name, export.path
1586 ));
1587 for symbol in &export.symbols {
1588 out.push_str(&format!("\n### {} `{}`\n\n", symbol.kind, symbol.name));
1589 if let Some(docs) = symbol.docs.as_deref() {
1590 out.push_str(docs);
1591 out.push_str("\n\n");
1592 }
1593 out.push_str("```harn\n");
1594 out.push_str(&symbol.signature);
1595 out.push_str("\n```\n");
1596 }
1597 }
1598 if !report.tools.is_empty() {
1599 out.push_str("\n## Tool Exports\n");
1600 for tool in &report.tools {
1601 out.push_str(&format!(
1602 "\n### `{}`\n\n- module: `{}`\n- symbol: `{}`\n",
1603 tool.name, tool.module, tool.symbol
1604 ));
1605 if !tool.permissions.is_empty() {
1606 out.push_str(&format!(
1607 "- permissions: `{}`\n",
1608 tool.permissions.join("`, `")
1609 ));
1610 }
1611 if !tool.host_requirements.is_empty() {
1612 out.push_str(&format!(
1613 "- host requirements: `{}`\n",
1614 tool.host_requirements.join("`, `")
1615 ));
1616 }
1617 }
1618 }
1619 if !report.skills.is_empty() {
1620 out.push_str("\n## Skill Exports\n");
1621 for skill in &report.skills {
1622 out.push_str(&format!("\n### `{}`\n\n`{}`\n", skill.name, skill.path));
1623 }
1624 }
1625 out
1626}
1627
1628pub(crate) fn normalize_newlines(input: &str) -> String {
1629 input.replace("\r\n", "\n")
1630}
1631
1632pub(crate) fn print_package_check_report(report: &PackageCheckReport) {
1633 println!(
1634 "Package {} {}",
1635 report.name.as_deref().unwrap_or("<unnamed>"),
1636 report.version.as_deref().unwrap_or("<unversioned>")
1637 );
1638 println!("manifest: {}", report.manifest_path);
1639 for export in &report.exports {
1640 println!(
1641 "export {} -> {} ({} public symbol(s))",
1642 export.name,
1643 export.path,
1644 export.symbols.len()
1645 );
1646 }
1647 for tool in &report.tools {
1648 println!("tool {} -> {}::{}", tool.name, tool.module, tool.symbol);
1649 }
1650 for skill in &report.skills {
1651 println!("skill {} -> {}", skill.name, skill.path);
1652 }
1653 if !report.warnings.is_empty() {
1654 println!("\nwarnings:");
1655 for warning in &report.warnings {
1656 println!("- {}: {}", warning.field, warning.message);
1657 }
1658 }
1659 if !report.errors.is_empty() {
1660 println!("\nerrors:");
1661 for error in &report.errors {
1662 println!("- {}: {}", error.field, error.message);
1663 }
1664 } else {
1665 println!("\npackage check passed");
1666 }
1667}
1668
1669pub(crate) fn print_package_pack_report(report: &PackagePackReport) {
1670 if report.dry_run {
1671 println!("Package pack dry run succeeded.");
1672 } else {
1673 println!("Packed package artifact.");
1674 }
1675 println!("artifact: {}", report.artifact_dir);
1676 println!("files:");
1677 for file in &report.files {
1678 println!("- {file}");
1679 }
1680}
1681
1682pub(crate) fn print_package_list_report(report: &PackageListReport) {
1683 println!("manifest: {}", report.manifest_path);
1684 println!("lock: {}", report.lock_path);
1685 if !report.lock_present {
1686 println!("lock status: missing");
1687 if report.dependency_count > 0 {
1688 println!(
1689 "run `harn install` to resolve {} dependency(s)",
1690 report.dependency_count
1691 );
1692 }
1693 return;
1694 }
1695 if report.packages.is_empty() {
1696 println!("No packages installed.");
1697 return;
1698 }
1699 println!("Packages ({}):", report.packages.len());
1700 for entry in &report.packages {
1701 let version = entry.package_version.as_deref().unwrap_or("unversioned");
1702 let status = if entry.materialized {
1703 "installed"
1704 } else {
1705 "missing"
1706 };
1707 println!(
1708 " {} {} {} integrity={}",
1709 entry.name, version, status, entry.integrity
1710 );
1711 if !entry.exports.modules.is_empty() {
1712 let modules: Vec<&str> = entry
1713 .exports
1714 .modules
1715 .iter()
1716 .map(|export| export.name.as_str())
1717 .collect();
1718 println!(" modules: {}", modules.join(", "));
1719 }
1720 if !entry.exports.tools.is_empty() {
1721 let tools: Vec<&str> = entry
1722 .exports
1723 .tools
1724 .iter()
1725 .map(|export| export.name.as_str())
1726 .collect();
1727 println!(" tools: {}", tools.join(", "));
1728 }
1729 if !entry.exports.skills.is_empty() {
1730 let skills: Vec<&str> = entry
1731 .exports
1732 .skills
1733 .iter()
1734 .map(|export| export.name.as_str())
1735 .collect();
1736 println!(" skills: {}", skills.join(", "));
1737 }
1738 if !entry.permissions.is_empty() {
1739 println!(" permissions: {}", entry.permissions.join(", "));
1740 }
1741 if !entry.host_requirements.is_empty() {
1742 println!(
1743 " host requirements: {}",
1744 entry.host_requirements.join(", ")
1745 );
1746 }
1747 }
1748}
1749
1750pub(crate) fn print_package_doctor_report(report: &PackageDoctorReport) {
1751 println!("Package doctor");
1752 println!("manifest: {}", report.manifest_path);
1753 println!("lock: {}", report.lock_path);
1754 if report.diagnostics.is_empty() {
1755 println!("ok: no package issues found");
1756 return;
1757 }
1758 for diagnostic in &report.diagnostics {
1759 println!(
1760 "{} [{}] {}",
1761 diagnostic.severity, diagnostic.code, diagnostic.message
1762 );
1763 if let Some(help) = diagnostic.help.as_deref() {
1764 println!(" help: {help}");
1765 }
1766 }
1767}
1768
1769#[cfg(test)]
1770mod tests {
1771 use super::*;
1772 use crate::package::test_support::*;
1773
1774 #[test]
1775 fn package_check_accepts_publishable_package() {
1776 let tmp = tempfile::tempdir().unwrap();
1777 write_publishable_package(tmp.path());
1778
1779 let report = check_package_impl(Some(tmp.path())).unwrap();
1780
1781 assert!(report.errors.is_empty(), "{:?}", report.errors);
1782 assert_eq!(report.name.as_deref(), Some("acme-lib"));
1783 assert_eq!(report.exports[0].symbols[0].name, "greet");
1784 }
1785
1786 #[test]
1787 fn package_check_rejects_path_dependencies_and_bad_harn_range() {
1788 let tmp = tempfile::tempdir().unwrap();
1789 write_publishable_package(tmp.path());
1790 fs::write(
1791 tmp.path().join(MANIFEST),
1792 r#"[package]
1793 name = "acme-lib"
1794 version = "0.1.0"
1795 description = "Acme helpers"
1796 license = "MIT"
1797 repository = "https://github.com/acme/acme-lib"
1798 harn = ">=999.0,<999.1"
1799 docs_url = "docs/api.md"
1800
1801 [exports]
1802 lib = "lib/main.harn"
1803
1804 [dependencies]
1805 local = { path = "../local" }
1806 "#,
1807 )
1808 .unwrap();
1809
1810 let report = check_package_impl(Some(tmp.path())).unwrap();
1811 let messages = report
1812 .errors
1813 .iter()
1814 .map(|diagnostic| diagnostic.message.as_str())
1815 .collect::<Vec<_>>()
1816 .join("\n");
1817
1818 assert!(messages.contains("unsupported Harn version range"));
1819 assert!(messages.contains("path dependencies are not publishable"));
1820 }
1821
1822 #[test]
1823 fn extract_api_symbols_recognizes_block_doc_comments() {
1824 let single = extract_api_symbols("/** Block doc. */\npub fn one() {}\n");
1829 assert_eq!(single.len(), 1);
1830 assert_eq!(single[0].docs.as_deref(), Some("Block doc."));
1831
1832 let multi =
1833 extract_api_symbols("/**\n * First line.\n * Second line.\n */\npub fn two() {}\n");
1834 assert_eq!(multi.len(), 1);
1835 assert_eq!(multi[0].docs.as_deref(), Some("First line.\nSecond line."));
1836
1837 let triple = extract_api_symbols("/// Slash doc.\npub fn three() {}\n");
1838 assert_eq!(triple.len(), 1);
1839 assert_eq!(triple[0].docs.as_deref(), Some("Slash doc."));
1840
1841 let detached = extract_api_symbols("/** Detached. */\nlet x = 1\npub fn four() {}\n");
1845 assert_eq!(detached.len(), 1);
1846 assert!(detached[0].docs.is_none());
1847 }
1848
1849 #[test]
1850 fn package_docs_and_pack_use_exports() {
1851 let tmp = tempfile::tempdir().unwrap();
1852 write_publishable_package(tmp.path());
1853
1854 let docs_path = generate_package_docs_impl(Some(tmp.path()), None, false).unwrap();
1855 let docs = fs::read_to_string(docs_path).unwrap();
1856 assert!(docs.contains("### fn `greet`"));
1857 assert!(docs.contains("Return a greeting."));
1858
1859 let pack = pack_package_impl(Some(tmp.path()), None, true).unwrap();
1860 assert!(pack.files.contains(&"harn.toml".to_string()));
1861 assert!(pack.files.contains(&"lib/main.harn".to_string()));
1862 }
1863
1864 #[test]
1865 fn package_pack_skips_generated_docs_dist() {
1866 let tmp = tempfile::tempdir().unwrap();
1867 write_publishable_package(tmp.path());
1868 fs::create_dir_all(tmp.path().join("docs/dist")).unwrap();
1869 fs::write(tmp.path().join("docs/dist/index.html"), "<html></html>\n").unwrap();
1870
1871 let pack = pack_package_impl(Some(tmp.path()), None, true).unwrap();
1872
1873 assert!(
1874 !pack.files.iter().any(|path| path.starts_with("docs/dist/")),
1875 "{:?}",
1876 pack.files
1877 );
1878 }
1879
1880 #[cfg(unix)]
1881 #[test]
1882 fn package_pack_does_not_follow_symlinked_files() {
1883 let tmp = tempfile::tempdir().unwrap();
1884 write_publishable_package(tmp.path());
1885 let outside = tempfile::NamedTempFile::new().unwrap();
1886 fs::write(outside.path(), "secret\n").unwrap();
1887 std::os::unix::fs::symlink(outside.path(), tmp.path().join("secret.txt")).unwrap();
1888
1889 let pack = pack_package_impl(Some(tmp.path()), None, true).unwrap();
1890
1891 assert!(
1892 !pack.files.contains(&"secret.txt".to_string()),
1893 "{:?}",
1894 pack.files
1895 );
1896 }
1897
1898 #[test]
1899 fn package_relative_paths_reject_windows_rooted_forms() {
1900 let tmp = tempfile::tempdir().unwrap();
1901 for rel_path in [
1902 "/repo/secret.harn",
1903 r"\repo\secret.harn",
1904 r"C:\repo\secret.harn",
1905 "C:secret.harn",
1906 r"\\server\share\secret.harn",
1907 ] {
1908 assert!(
1909 safe_package_relative_path(tmp.path(), rel_path).is_err(),
1910 "{rel_path:?} must not be accepted as package-relative"
1911 );
1912 }
1913 }
1914
1915 #[test]
1916 fn package_check_validates_tool_and_skill_exports() {
1917 let tmp = tempfile::tempdir().unwrap();
1918 write_publishable_package(tmp.path());
1919 fs::create_dir_all(tmp.path().join("skills/review")).unwrap();
1920 fs::write(
1921 tmp.path().join("harn.toml"),
1922 format!(
1923 r#"[package]
1924name = "acme-lib"
1925version = "0.1.0"
1926description = "Acme helpers"
1927license = "MIT"
1928repository = "https://github.com/acme/acme-lib"
1929harn = "{}"
1930docs_url = "docs/api.md"
1931permissions = ["tool:read_only"]
1932host_requirements = ["workspace.read_text"]
1933
1934[exports]
1935lib = "lib/main.harn"
1936
1937[[package.tools]]
1938name = "read-note"
1939module = "lib/main.harn"
1940symbol = "tools"
1941permissions = ["tool:read_only"]
1942
1943[package.tools.input_schema]
1944type = "object"
1945required = ["path"]
1946
1947[package.tools.annotations]
1948kind = "read"
1949side_effect_level = "read_only"
1950
1951[package.tools.annotations.arg_schema]
1952required = ["path"]
1953
1954[[package.skills]]
1955name = "review"
1956path = "skills/review"
1957permissions = ["skill:prompt"]
1958
1959[dependencies]
1960"#,
1961 current_harn_range_example()
1962 ),
1963 )
1964 .unwrap();
1965 fs::write(
1966 tmp.path().join("skills/review/SKILL.md"),
1967 "---\nname: review\nshort: Review changes\n---\n# Review\n",
1968 )
1969 .unwrap();
1970
1971 let report = check_package_impl(Some(tmp.path())).unwrap();
1972
1973 assert!(report.errors.is_empty(), "{:?}", report.errors);
1974 assert_eq!(report.tools[0].name, "read-note");
1975 assert_eq!(
1976 report.tools[0].host_requirements,
1977 vec!["workspace.read_text"]
1978 );
1979 assert_eq!(report.skills[0].name, "review");
1980 }
1981
1982 #[test]
1983 fn package_check_rejects_invalid_tool_schema_and_host_requirement() {
1984 let tmp = tempfile::tempdir().unwrap();
1985 write_publishable_package(tmp.path());
1986 fs::write(
1987 tmp.path().join(MANIFEST),
1988 format!(
1989 r#"[package]
1990name = "acme-lib"
1991version = "0.1.0"
1992description = "Acme helpers"
1993license = "MIT"
1994repository = "https://github.com/acme/acme-lib"
1995harn = "{}"
1996docs_url = "docs/api.md"
1997
1998[exports]
1999lib = "lib/main.harn"
2000
2001[[package.tools]]
2002name = "broken"
2003module = "lib/main.harn"
2004symbol = "tools"
2005host_requirements = ["workspace"]
2006
2007[package.tools.input_schema]
2008required = [1]
2009
2010[dependencies]
2011"#,
2012 current_harn_range_example()
2013 ),
2014 )
2015 .unwrap();
2016
2017 let report = check_package_impl(Some(tmp.path())).unwrap();
2018 let messages = report
2019 .errors
2020 .iter()
2021 .map(|diagnostic| diagnostic.message.as_str())
2022 .collect::<Vec<_>>()
2023 .join("\n");
2024
2025 assert!(messages.contains("capability.operation"));
2026 assert!(messages.contains("schema `required` must be a list of strings"));
2027 }
2028
2029 #[test]
2030 fn package_doctor_accepts_application_manifests_with_tool_exports() {
2031 let tmp = tempfile::tempdir().unwrap();
2032 fs::write(
2033 tmp.path().join(MANIFEST),
2034 r#"[package]
2035name = "acme-app"
2036
2037[[package.tools]]
2038name = "echo"
2039module = "tools.harn"
2040symbol = "tools"
2041
2042[package.tools.input_schema]
2043type = "object"
2044
2045[package.tools.annotations]
2046kind = "read"
2047side_effect_level = "read_only"
2048"#,
2049 )
2050 .unwrap();
2051 fs::write(tmp.path().join("tools.harn"), "pub fn tools() {}\n").unwrap();
2052 let workspace = TestWorkspace::new(tmp.path());
2053
2054 let report = doctor_packages_in(workspace.env()).unwrap();
2055
2056 assert!(report.ok, "{:?}", report.diagnostics);
2057 assert!(
2058 report
2059 .diagnostics
2060 .iter()
2061 .all(|diagnostic| diagnostic.code != "root-package-check"),
2062 "{:?}",
2063 report.diagnostics
2064 );
2065 }
2066
2067 #[test]
2068 fn package_list_reports_locked_tool_and_skill_exports() {
2069 let tmp = tempfile::tempdir().unwrap();
2070 fs::write(
2071 tmp.path().join(MANIFEST),
2072 r#"[package]
2073name = "consumer"
2074"#,
2075 )
2076 .unwrap();
2077 let lock = LockFile {
2078 packages: vec![LockEntry {
2079 name: "acme-tools".to_string(),
2080 source: "path+../acme-tools".to_string(),
2081 package_version: Some("0.1.0".to_string()),
2082 provenance: Some(
2083 "https://github.com/acme/acme-tools/releases/tag/v0.1.0".to_string(),
2084 ),
2085 exports: PackageLockExports {
2086 modules: vec![PackageLockExport {
2087 name: "tools".to_string(),
2088 path: Some("lib/tools.harn".to_string()),
2089 symbol: None,
2090 }],
2091 tools: vec![PackageLockExport {
2092 name: "echo".to_string(),
2093 path: Some("lib/tools.harn".to_string()),
2094 symbol: Some("tools".to_string()),
2095 }],
2096 skills: vec![PackageLockExport {
2097 name: "review".to_string(),
2098 path: Some("skills/review".to_string()),
2099 symbol: None,
2100 }],
2101 personas: Vec::new(),
2102 },
2103 permissions: vec!["tool:read_only".to_string()],
2104 host_requirements: vec!["workspace.read_text".to_string()],
2105 ..LockEntry::default()
2106 }],
2107 ..LockFile::default()
2108 };
2109 let lock_body = toml::to_string_pretty(&lock).unwrap();
2110 fs::write(tmp.path().join(LOCK_FILE), lock_body).unwrap();
2111 let workspace = TestWorkspace::new(tmp.path());
2112
2113 let report = list_packages_in(workspace.env()).unwrap();
2114
2115 assert_eq!(report.packages.len(), 1);
2116 let package = &report.packages[0];
2117 assert_eq!(package.name, "acme-tools");
2118 assert_eq!(
2119 package.provenance.as_deref(),
2120 Some("https://github.com/acme/acme-tools/releases/tag/v0.1.0")
2121 );
2122 assert_eq!(package.exports.tools[0].name, "echo");
2123 assert_eq!(package.exports.skills[0].name, "review");
2124 assert_eq!(package.permissions, vec!["tool:read_only"]);
2125 assert_eq!(package.host_requirements, vec!["workspace.read_text"]);
2126 }
2127}