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 || rel
1304 .components()
1305 .any(|component| matches!(component, std::path::Component::ParentDir))
1306 {
1307 return Err(format!("path {rel_path:?} escapes package root").into());
1308 }
1309 Ok(root.join(rel))
1310}
1311
1312pub(crate) fn extract_api_symbols(source: &str) -> Vec<PackageApiSymbol> {
1313 static DECL_RE: OnceLock<Regex> = OnceLock::new();
1314 let decl_re = DECL_RE.get_or_init(|| {
1315 Regex::new(r"^\s*pub\s+(fn|pipeline|tool|skill|struct|enum|type|interface)\s+([A-Za-z_][A-Za-z0-9_]*)\b(.*)$")
1316 .expect("valid declaration regex")
1317 });
1318 let mut docs: Vec<String> = Vec::new();
1319 let mut symbols = Vec::new();
1320 let mut in_block_doc = false;
1321 for line in source.lines() {
1322 let trimmed = line.trim();
1323 if in_block_doc {
1324 let (content, closes) = match trimmed.split_once("*/") {
1328 Some((before, _)) => (before, true),
1329 None => (trimmed, false),
1330 };
1331 let stripped = content
1332 .strip_prefix("* ")
1333 .or_else(|| content.strip_prefix('*'))
1334 .unwrap_or(content)
1335 .trim();
1336 if !stripped.is_empty() {
1337 docs.push(stripped.to_string());
1338 }
1339 if closes {
1340 in_block_doc = false;
1341 }
1342 continue;
1343 }
1344 if let Some(doc) = trimmed.strip_prefix("///") {
1345 docs.push(doc.trim().to_string());
1346 continue;
1347 }
1348 if let Some(rest) = trimmed.strip_prefix("/**") {
1349 if let Some((inner, _)) = rest.split_once("*/") {
1353 let stripped = inner.trim();
1354 if !stripped.is_empty() {
1355 docs.push(stripped.to_string());
1356 }
1357 } else {
1358 let stripped = rest.trim();
1359 if !stripped.is_empty() {
1360 docs.push(stripped.to_string());
1361 }
1362 in_block_doc = true;
1363 }
1364 continue;
1365 }
1366 if trimmed.is_empty() {
1367 continue;
1368 }
1369 if let Some(captures) = decl_re.captures(line) {
1370 let kind = captures.get(1).expect("kind").as_str().to_string();
1371 let name = captures.get(2).expect("name").as_str().to_string();
1372 let signature = trim_signature(line);
1373 let doc_text = (!docs.is_empty()).then(|| docs.join("\n"));
1374 symbols.push(PackageApiSymbol {
1375 kind,
1376 name,
1377 signature,
1378 docs: doc_text,
1379 });
1380 }
1381 docs.clear();
1382 }
1383 symbols
1384}
1385
1386pub(crate) fn trim_signature(line: &str) -> String {
1387 let mut signature = line.trim().to_string();
1388 if let Some((before, _)) = signature.split_once('{') {
1389 signature = before.trim_end().to_string();
1390 }
1391 signature
1392}
1393
1394pub(crate) fn supports_current_harn(range: &str) -> bool {
1395 let current = env!("CARGO_PKG_VERSION");
1396 let Some((major, minor)) = parse_major_minor(current) else {
1397 return true;
1398 };
1399 let range = range.trim();
1400 if range.is_empty() {
1401 return false;
1402 }
1403 if let Some(rest) = range.strip_prefix('^') {
1404 return parse_major_minor(rest).is_some_and(|(m, n)| m == major && n == minor);
1405 }
1406 if !range.contains([',', '<', '>', '=']) {
1407 return parse_major_minor(range).is_some_and(|(m, n)| m == major && n == minor);
1408 }
1409
1410 let current_value = major * 1000 + minor;
1411 let mut lower_ok = true;
1412 let mut upper_ok = true;
1413 let mut saw_constraint = false;
1414 for raw in range.split(',') {
1415 let part = raw.trim();
1416 if part.is_empty() {
1417 continue;
1418 }
1419 saw_constraint = true;
1420 if let Some(rest) = part.strip_prefix(">=") {
1421 if let Some((m, n)) = parse_major_minor(rest.trim()) {
1422 lower_ok &= current_value >= m * 1000 + n;
1423 } else {
1424 return false;
1425 }
1426 } else if let Some(rest) = part.strip_prefix('>') {
1427 if let Some((m, n)) = parse_major_minor(rest.trim()) {
1428 lower_ok &= current_value > m * 1000 + n;
1429 } else {
1430 return false;
1431 }
1432 } else if let Some(rest) = part.strip_prefix("<=") {
1433 if let Some((m, n)) = parse_major_minor(rest.trim()) {
1434 upper_ok &= current_value <= m * 1000 + n;
1435 } else {
1436 return false;
1437 }
1438 } else if let Some(rest) = part.strip_prefix('<') {
1439 if let Some((m, n)) = parse_major_minor(rest.trim()) {
1440 upper_ok &= current_value < m * 1000 + n;
1441 } else {
1442 return false;
1443 }
1444 } else if let Some(rest) = part.strip_prefix('=') {
1445 if let Some((m, n)) = parse_major_minor(rest.trim()) {
1446 lower_ok &= current_value == m * 1000 + n;
1447 upper_ok &= current_value == m * 1000 + n;
1448 } else {
1449 return false;
1450 }
1451 } else {
1452 return false;
1453 }
1454 }
1455 saw_constraint && lower_ok && upper_ok
1456}
1457
1458pub(crate) fn current_harn_range_example() -> String {
1459 let current = env!("CARGO_PKG_VERSION");
1460 let Some((major, minor)) = parse_major_minor(current) else {
1461 return ">=0.7,<0.8".to_string();
1462 };
1463 format!(">={major}.{minor},<{major}.{}", minor + 1)
1464}
1465
1466pub(crate) fn current_harn_line_label() -> String {
1467 let current = env!("CARGO_PKG_VERSION");
1468 let Some((major, minor)) = parse_major_minor(current) else {
1469 return "0.7".to_string();
1470 };
1471 format!("{major}.{minor}")
1472}
1473
1474pub(crate) fn parse_major_minor(raw: &str) -> Option<(u64, u64)> {
1475 let raw = raw.trim().trim_start_matches('v');
1476 let mut parts = raw.split('.');
1477 let major = parts.next()?.parse().ok()?;
1478 let minor = parts.next()?.trim_end_matches('x').parse().ok()?;
1479 Some((major, minor))
1480}
1481
1482pub(crate) fn collect_package_files(root: &Path) -> Result<Vec<String>, PackageError> {
1483 let mut files = Vec::new();
1484 collect_package_files_inner(root, root, &mut files)?;
1485 files.sort();
1486 Ok(files)
1487}
1488
1489pub(crate) fn collect_package_files_inner(
1490 root: &Path,
1491 dir: &Path,
1492 out: &mut Vec<String>,
1493) -> Result<(), PackageError> {
1494 for entry in
1495 fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?
1496 {
1497 let entry =
1498 entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?;
1499 let path = entry.path();
1500 let name = entry.file_name();
1501 if path.is_dir() {
1502 if should_skip_package_dir(&name) {
1503 continue;
1504 }
1505 collect_package_files_inner(root, &path, out)?;
1506 } else if path.is_file() {
1507 let rel = path
1508 .strip_prefix(root)
1509 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?
1510 .to_string_lossy()
1511 .replace('\\', "/");
1512 out.push(rel);
1513 }
1514 }
1515 Ok(())
1516}
1517
1518pub(crate) fn should_skip_package_dir(name: &OsStr) -> bool {
1519 matches!(
1520 name.to_str(),
1521 Some(".git" | ".harn" | "target" | "node_modules" | "docs/dist")
1522 )
1523}
1524
1525pub(crate) fn default_artifact_dir(ctx: &ManifestContext, report: &PackageCheckReport) -> PathBuf {
1526 let name = report.name.as_deref().unwrap_or("package");
1527 let version = report.version.as_deref().unwrap_or("0.0.0");
1528 ctx.dir
1529 .join(".harn")
1530 .join("dist")
1531 .join(format!("{name}-{version}"))
1532}
1533
1534pub(crate) fn fail_if_package_errors(report: &PackageCheckReport) -> Result<(), PackageError> {
1535 if report.errors.is_empty() {
1536 return Ok(());
1537 }
1538 Err(format!(
1539 "package check failed:\n{}",
1540 report
1541 .errors
1542 .iter()
1543 .map(|diagnostic| format!("- {}: {}", diagnostic.field, diagnostic.message))
1544 .collect::<Vec<_>>()
1545 .join("\n")
1546 )
1547 .into())
1548}
1549
1550pub(crate) fn render_package_api_docs(report: &PackageCheckReport) -> String {
1551 let title = report.name.as_deref().unwrap_or("package");
1552 let mut out = format!("# API Reference: {title}\n\nGenerated by `harn package docs`.\n");
1553 if let Some(version) = report.version.as_deref() {
1554 out.push_str(&format!("\nVersion: `{version}`\n"));
1555 }
1556 for export in &report.exports {
1557 out.push_str(&format!(
1558 "\n## Export `{}`\n\n`{}`\n",
1559 export.name, export.path
1560 ));
1561 for symbol in &export.symbols {
1562 out.push_str(&format!("\n### {} `{}`\n\n", symbol.kind, symbol.name));
1563 if let Some(docs) = symbol.docs.as_deref() {
1564 out.push_str(docs);
1565 out.push_str("\n\n");
1566 }
1567 out.push_str("```harn\n");
1568 out.push_str(&symbol.signature);
1569 out.push_str("\n```\n");
1570 }
1571 }
1572 if !report.tools.is_empty() {
1573 out.push_str("\n## Tool Exports\n");
1574 for tool in &report.tools {
1575 out.push_str(&format!(
1576 "\n### `{}`\n\n- module: `{}`\n- symbol: `{}`\n",
1577 tool.name, tool.module, tool.symbol
1578 ));
1579 if !tool.permissions.is_empty() {
1580 out.push_str(&format!(
1581 "- permissions: `{}`\n",
1582 tool.permissions.join("`, `")
1583 ));
1584 }
1585 if !tool.host_requirements.is_empty() {
1586 out.push_str(&format!(
1587 "- host requirements: `{}`\n",
1588 tool.host_requirements.join("`, `")
1589 ));
1590 }
1591 }
1592 }
1593 if !report.skills.is_empty() {
1594 out.push_str("\n## Skill Exports\n");
1595 for skill in &report.skills {
1596 out.push_str(&format!("\n### `{}`\n\n`{}`\n", skill.name, skill.path));
1597 }
1598 }
1599 out
1600}
1601
1602pub(crate) fn normalize_newlines(input: &str) -> String {
1603 input.replace("\r\n", "\n")
1604}
1605
1606pub(crate) fn print_package_check_report(report: &PackageCheckReport) {
1607 println!(
1608 "Package {} {}",
1609 report.name.as_deref().unwrap_or("<unnamed>"),
1610 report.version.as_deref().unwrap_or("<unversioned>")
1611 );
1612 println!("manifest: {}", report.manifest_path);
1613 for export in &report.exports {
1614 println!(
1615 "export {} -> {} ({} public symbol(s))",
1616 export.name,
1617 export.path,
1618 export.symbols.len()
1619 );
1620 }
1621 for tool in &report.tools {
1622 println!("tool {} -> {}::{}", tool.name, tool.module, tool.symbol);
1623 }
1624 for skill in &report.skills {
1625 println!("skill {} -> {}", skill.name, skill.path);
1626 }
1627 if !report.warnings.is_empty() {
1628 println!("\nwarnings:");
1629 for warning in &report.warnings {
1630 println!("- {}: {}", warning.field, warning.message);
1631 }
1632 }
1633 if !report.errors.is_empty() {
1634 println!("\nerrors:");
1635 for error in &report.errors {
1636 println!("- {}: {}", error.field, error.message);
1637 }
1638 } else {
1639 println!("\npackage check passed");
1640 }
1641}
1642
1643pub(crate) fn print_package_pack_report(report: &PackagePackReport) {
1644 if report.dry_run {
1645 println!("Package pack dry run succeeded.");
1646 } else {
1647 println!("Packed package artifact.");
1648 }
1649 println!("artifact: {}", report.artifact_dir);
1650 println!("files:");
1651 for file in &report.files {
1652 println!("- {file}");
1653 }
1654}
1655
1656pub(crate) fn print_package_list_report(report: &PackageListReport) {
1657 println!("manifest: {}", report.manifest_path);
1658 println!("lock: {}", report.lock_path);
1659 if !report.lock_present {
1660 println!("lock status: missing");
1661 if report.dependency_count > 0 {
1662 println!(
1663 "run `harn install` to resolve {} dependency(s)",
1664 report.dependency_count
1665 );
1666 }
1667 return;
1668 }
1669 if report.packages.is_empty() {
1670 println!("No packages installed.");
1671 return;
1672 }
1673 println!("Packages ({}):", report.packages.len());
1674 for entry in &report.packages {
1675 let version = entry.package_version.as_deref().unwrap_or("unversioned");
1676 let status = if entry.materialized {
1677 "installed"
1678 } else {
1679 "missing"
1680 };
1681 println!(
1682 " {} {} {} integrity={}",
1683 entry.name, version, status, entry.integrity
1684 );
1685 if !entry.exports.modules.is_empty() {
1686 let modules: Vec<&str> = entry
1687 .exports
1688 .modules
1689 .iter()
1690 .map(|export| export.name.as_str())
1691 .collect();
1692 println!(" modules: {}", modules.join(", "));
1693 }
1694 if !entry.exports.tools.is_empty() {
1695 let tools: Vec<&str> = entry
1696 .exports
1697 .tools
1698 .iter()
1699 .map(|export| export.name.as_str())
1700 .collect();
1701 println!(" tools: {}", tools.join(", "));
1702 }
1703 if !entry.exports.skills.is_empty() {
1704 let skills: Vec<&str> = entry
1705 .exports
1706 .skills
1707 .iter()
1708 .map(|export| export.name.as_str())
1709 .collect();
1710 println!(" skills: {}", skills.join(", "));
1711 }
1712 if !entry.permissions.is_empty() {
1713 println!(" permissions: {}", entry.permissions.join(", "));
1714 }
1715 if !entry.host_requirements.is_empty() {
1716 println!(
1717 " host requirements: {}",
1718 entry.host_requirements.join(", ")
1719 );
1720 }
1721 }
1722}
1723
1724pub(crate) fn print_package_doctor_report(report: &PackageDoctorReport) {
1725 println!("Package doctor");
1726 println!("manifest: {}", report.manifest_path);
1727 println!("lock: {}", report.lock_path);
1728 if report.diagnostics.is_empty() {
1729 println!("ok: no package issues found");
1730 return;
1731 }
1732 for diagnostic in &report.diagnostics {
1733 println!(
1734 "{} [{}] {}",
1735 diagnostic.severity, diagnostic.code, diagnostic.message
1736 );
1737 if let Some(help) = diagnostic.help.as_deref() {
1738 println!(" help: {help}");
1739 }
1740 }
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745 use super::*;
1746 use crate::package::test_support::*;
1747
1748 #[test]
1749 fn package_check_accepts_publishable_package() {
1750 let tmp = tempfile::tempdir().unwrap();
1751 write_publishable_package(tmp.path());
1752
1753 let report = check_package_impl(Some(tmp.path())).unwrap();
1754
1755 assert!(report.errors.is_empty(), "{:?}", report.errors);
1756 assert_eq!(report.name.as_deref(), Some("acme-lib"));
1757 assert_eq!(report.exports[0].symbols[0].name, "greet");
1758 }
1759
1760 #[test]
1761 fn package_check_rejects_path_dependencies_and_bad_harn_range() {
1762 let tmp = tempfile::tempdir().unwrap();
1763 write_publishable_package(tmp.path());
1764 fs::write(
1765 tmp.path().join(MANIFEST),
1766 r#"[package]
1767 name = "acme-lib"
1768 version = "0.1.0"
1769 description = "Acme helpers"
1770 license = "MIT"
1771 repository = "https://github.com/acme/acme-lib"
1772 harn = ">=999.0,<999.1"
1773 docs_url = "docs/api.md"
1774
1775 [exports]
1776 lib = "lib/main.harn"
1777
1778 [dependencies]
1779 local = { path = "../local" }
1780 "#,
1781 )
1782 .unwrap();
1783
1784 let report = check_package_impl(Some(tmp.path())).unwrap();
1785 let messages = report
1786 .errors
1787 .iter()
1788 .map(|diagnostic| diagnostic.message.as_str())
1789 .collect::<Vec<_>>()
1790 .join("\n");
1791
1792 assert!(messages.contains("unsupported Harn version range"));
1793 assert!(messages.contains("path dependencies are not publishable"));
1794 }
1795
1796 #[test]
1797 fn extract_api_symbols_recognizes_block_doc_comments() {
1798 let single = extract_api_symbols("/** Block doc. */\npub fn one() {}\n");
1803 assert_eq!(single.len(), 1);
1804 assert_eq!(single[0].docs.as_deref(), Some("Block doc."));
1805
1806 let multi =
1807 extract_api_symbols("/**\n * First line.\n * Second line.\n */\npub fn two() {}\n");
1808 assert_eq!(multi.len(), 1);
1809 assert_eq!(multi[0].docs.as_deref(), Some("First line.\nSecond line."));
1810
1811 let triple = extract_api_symbols("/// Slash doc.\npub fn three() {}\n");
1812 assert_eq!(triple.len(), 1);
1813 assert_eq!(triple[0].docs.as_deref(), Some("Slash doc."));
1814
1815 let detached = extract_api_symbols("/** Detached. */\nlet x = 1\npub fn four() {}\n");
1819 assert_eq!(detached.len(), 1);
1820 assert!(detached[0].docs.is_none());
1821 }
1822
1823 #[test]
1824 fn package_docs_and_pack_use_exports() {
1825 let tmp = tempfile::tempdir().unwrap();
1826 write_publishable_package(tmp.path());
1827
1828 let docs_path = generate_package_docs_impl(Some(tmp.path()), None, false).unwrap();
1829 let docs = fs::read_to_string(docs_path).unwrap();
1830 assert!(docs.contains("### fn `greet`"));
1831 assert!(docs.contains("Return a greeting."));
1832
1833 let pack = pack_package_impl(Some(tmp.path()), None, true).unwrap();
1834 assert!(pack.files.contains(&"harn.toml".to_string()));
1835 assert!(pack.files.contains(&"lib/main.harn".to_string()));
1836 }
1837
1838 #[test]
1839 fn package_check_validates_tool_and_skill_exports() {
1840 let tmp = tempfile::tempdir().unwrap();
1841 write_publishable_package(tmp.path());
1842 fs::create_dir_all(tmp.path().join("skills/review")).unwrap();
1843 fs::write(
1844 tmp.path().join("harn.toml"),
1845 format!(
1846 r#"[package]
1847name = "acme-lib"
1848version = "0.1.0"
1849description = "Acme helpers"
1850license = "MIT"
1851repository = "https://github.com/acme/acme-lib"
1852harn = "{}"
1853docs_url = "docs/api.md"
1854permissions = ["tool:read_only"]
1855host_requirements = ["workspace.read_text"]
1856
1857[exports]
1858lib = "lib/main.harn"
1859
1860[[package.tools]]
1861name = "read-note"
1862module = "lib/main.harn"
1863symbol = "tools"
1864permissions = ["tool:read_only"]
1865
1866[package.tools.input_schema]
1867type = "object"
1868required = ["path"]
1869
1870[package.tools.annotations]
1871kind = "read"
1872side_effect_level = "read_only"
1873
1874[package.tools.annotations.arg_schema]
1875required = ["path"]
1876
1877[[package.skills]]
1878name = "review"
1879path = "skills/review"
1880permissions = ["skill:prompt"]
1881
1882[dependencies]
1883"#,
1884 current_harn_range_example()
1885 ),
1886 )
1887 .unwrap();
1888 fs::write(
1889 tmp.path().join("skills/review/SKILL.md"),
1890 "---\nname: review\nshort: Review changes\n---\n# Review\n",
1891 )
1892 .unwrap();
1893
1894 let report = check_package_impl(Some(tmp.path())).unwrap();
1895
1896 assert!(report.errors.is_empty(), "{:?}", report.errors);
1897 assert_eq!(report.tools[0].name, "read-note");
1898 assert_eq!(
1899 report.tools[0].host_requirements,
1900 vec!["workspace.read_text"]
1901 );
1902 assert_eq!(report.skills[0].name, "review");
1903 }
1904
1905 #[test]
1906 fn package_check_rejects_invalid_tool_schema_and_host_requirement() {
1907 let tmp = tempfile::tempdir().unwrap();
1908 write_publishable_package(tmp.path());
1909 fs::write(
1910 tmp.path().join(MANIFEST),
1911 format!(
1912 r#"[package]
1913name = "acme-lib"
1914version = "0.1.0"
1915description = "Acme helpers"
1916license = "MIT"
1917repository = "https://github.com/acme/acme-lib"
1918harn = "{}"
1919docs_url = "docs/api.md"
1920
1921[exports]
1922lib = "lib/main.harn"
1923
1924[[package.tools]]
1925name = "broken"
1926module = "lib/main.harn"
1927symbol = "tools"
1928host_requirements = ["workspace"]
1929
1930[package.tools.input_schema]
1931required = [1]
1932
1933[dependencies]
1934"#,
1935 current_harn_range_example()
1936 ),
1937 )
1938 .unwrap();
1939
1940 let report = check_package_impl(Some(tmp.path())).unwrap();
1941 let messages = report
1942 .errors
1943 .iter()
1944 .map(|diagnostic| diagnostic.message.as_str())
1945 .collect::<Vec<_>>()
1946 .join("\n");
1947
1948 assert!(messages.contains("capability.operation"));
1949 assert!(messages.contains("schema `required` must be a list of strings"));
1950 }
1951
1952 #[test]
1953 fn package_doctor_accepts_application_manifests_with_tool_exports() {
1954 let tmp = tempfile::tempdir().unwrap();
1955 fs::write(
1956 tmp.path().join(MANIFEST),
1957 r#"[package]
1958name = "acme-app"
1959
1960[[package.tools]]
1961name = "echo"
1962module = "tools.harn"
1963symbol = "tools"
1964
1965[package.tools.input_schema]
1966type = "object"
1967
1968[package.tools.annotations]
1969kind = "read"
1970side_effect_level = "read_only"
1971"#,
1972 )
1973 .unwrap();
1974 fs::write(tmp.path().join("tools.harn"), "pub fn tools() {}\n").unwrap();
1975 let workspace = TestWorkspace::new(tmp.path());
1976
1977 let report = doctor_packages_in(workspace.env()).unwrap();
1978
1979 assert!(report.ok, "{:?}", report.diagnostics);
1980 assert!(
1981 report
1982 .diagnostics
1983 .iter()
1984 .all(|diagnostic| diagnostic.code != "root-package-check"),
1985 "{:?}",
1986 report.diagnostics
1987 );
1988 }
1989
1990 #[test]
1991 fn package_list_reports_locked_tool_and_skill_exports() {
1992 let tmp = tempfile::tempdir().unwrap();
1993 fs::write(
1994 tmp.path().join(MANIFEST),
1995 r#"[package]
1996name = "consumer"
1997"#,
1998 )
1999 .unwrap();
2000 let lock = LockFile {
2001 packages: vec![LockEntry {
2002 name: "acme-tools".to_string(),
2003 source: "path+../acme-tools".to_string(),
2004 package_version: Some("0.1.0".to_string()),
2005 provenance: Some(
2006 "https://github.com/acme/acme-tools/releases/tag/v0.1.0".to_string(),
2007 ),
2008 exports: PackageLockExports {
2009 modules: vec![PackageLockExport {
2010 name: "tools".to_string(),
2011 path: Some("lib/tools.harn".to_string()),
2012 symbol: None,
2013 }],
2014 tools: vec![PackageLockExport {
2015 name: "echo".to_string(),
2016 path: Some("lib/tools.harn".to_string()),
2017 symbol: Some("tools".to_string()),
2018 }],
2019 skills: vec![PackageLockExport {
2020 name: "review".to_string(),
2021 path: Some("skills/review".to_string()),
2022 symbol: None,
2023 }],
2024 personas: Vec::new(),
2025 },
2026 permissions: vec!["tool:read_only".to_string()],
2027 host_requirements: vec!["workspace.read_text".to_string()],
2028 ..LockEntry::default()
2029 }],
2030 ..LockFile::default()
2031 };
2032 let lock_body = toml::to_string_pretty(&lock).unwrap();
2033 fs::write(tmp.path().join(LOCK_FILE), lock_body).unwrap();
2034 let workspace = TestWorkspace::new(tmp.path());
2035
2036 let report = list_packages_in(workspace.env()).unwrap();
2037
2038 assert_eq!(report.packages.len(), 1);
2039 let package = &report.packages[0];
2040 assert_eq!(package.name, "acme-tools");
2041 assert_eq!(
2042 package.provenance.as_deref(),
2043 Some("https://github.com/acme/acme-tools/releases/tag/v0.1.0")
2044 );
2045 assert_eq!(package.exports.tools[0].name, "echo");
2046 assert_eq!(package.exports.skills[0].name, "review");
2047 assert_eq!(package.permissions, vec!["tool:read_only"]);
2048 assert_eq!(package.host_requirements, vec!["workspace.read_text"]);
2049 }
2050}