1use super::errors::PackageError;
2use super::*;
3use base64::Engine as _;
4
5#[derive(Debug, Clone, Serialize)]
6pub struct PackageCheckReport {
7 pub package_dir: String,
8 pub manifest_path: String,
9 pub name: Option<String>,
10 pub version: Option<String>,
11 pub errors: Vec<PackageCheckDiagnostic>,
12 pub warnings: Vec<PackageCheckDiagnostic>,
13 pub exports: Vec<PackageExportReport>,
14 pub tools: Vec<PackageToolExportReport>,
15 pub skills: Vec<PackageSkillExportReport>,
16}
17
18#[derive(Debug, Clone, Serialize)]
19pub struct PackageCheckDiagnostic {
20 pub field: String,
21 pub message: String,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct PackageExportReport {
26 pub name: String,
27 pub path: String,
28 pub symbols: Vec<PackageApiSymbol>,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct PackageToolExportReport {
33 pub name: String,
34 pub module: String,
35 pub symbol: String,
36 pub permissions: Vec<String>,
37 pub host_requirements: Vec<String>,
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct PackageSkillExportReport {
42 pub name: String,
43 pub path: String,
44 pub permissions: Vec<String>,
45 pub host_requirements: Vec<String>,
46}
47
48#[derive(Debug, Clone, Serialize)]
49pub struct PackageApiSymbol {
50 pub kind: String,
51 pub name: String,
52 pub signature: String,
53 pub docs: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct PackagePackReport {
58 pub package_dir: String,
59 pub artifact_dir: String,
60 pub dry_run: bool,
61 pub files: Vec<String>,
62 pub check: PackageCheckReport,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct PackagePublishReport {
67 pub dry_run: bool,
68 pub registry: String,
69 pub artifact_dir: String,
70 pub files: Vec<String>,
71 pub tag: String,
72 pub sha: String,
73 pub remote: String,
74 pub index_repo: String,
75 pub index_path: String,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub index_pr_url: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub tag_command: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub index_diff: Option<String>,
82 pub check: PackageCheckReport,
83}
84
85#[derive(Debug, Clone)]
86pub(crate) struct PackagePublishOptions<'a> {
87 pub(crate) dry_run: bool,
88 pub(crate) remote: &'a str,
89 pub(crate) index_repo: &'a str,
90 pub(crate) index_path: &'a Path,
91 pub(crate) registry_name: Option<&'a str>,
92 pub(crate) skip_index_pr: bool,
93 pub(crate) registry: Option<&'a str>,
94}
95
96#[derive(Debug, Clone)]
97struct PackagePublishPlan {
98 repo_root: PathBuf,
99 package_name: String,
100 registry_name: String,
101 version: String,
102 tag: String,
103 sha: String,
104 git: String,
105 remote: String,
106 index_repo: String,
107 index_path: PathBuf,
108 updated_index_content: String,
109 index_diff: String,
110 tag_command: String,
111 pack: PackagePackReport,
112}
113
114#[derive(Debug, Clone, Serialize)]
115pub struct PackageListReport {
116 pub manifest_path: String,
117 pub lock_path: String,
118 pub lock_present: bool,
119 pub dependency_count: usize,
120 pub packages: Vec<PackageListEntry>,
121}
122
123#[derive(Debug, Clone, Serialize)]
124pub struct PackageListEntry {
125 pub name: String,
126 pub source: String,
127 pub package_version: Option<String>,
128 pub harn_compat: Option<String>,
129 pub provenance: Option<String>,
130 pub materialized: bool,
131 pub integrity: String,
132 pub exports: PackageLockExports,
133 pub permissions: Vec<String>,
134 pub host_requirements: Vec<String>,
135}
136
137#[derive(Debug, Clone, Serialize)]
138pub struct PackageDoctorReport {
139 pub ok: bool,
140 pub manifest_path: String,
141 pub lock_path: String,
142 pub diagnostics: Vec<PackageDoctorDiagnostic>,
143 pub packages: Vec<PackageListEntry>,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct PackageDoctorDiagnostic {
148 pub severity: String,
149 pub code: String,
150 pub message: String,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub help: Option<String>,
153}
154
155pub fn check_package(anchor: Option<&Path>, json: bool) {
156 match check_package_impl(anchor) {
157 Ok(report) => {
158 if json {
159 println!(
160 "{}",
161 serde_json::to_string_pretty(&report)
162 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
163 );
164 } else {
165 print_package_check_report(&report);
166 }
167 if !report.errors.is_empty() {
168 process::exit(1);
169 }
170 }
171 Err(error) => {
172 eprintln!("error: {error}");
173 process::exit(1);
174 }
175 }
176}
177
178pub fn pack_package(anchor: Option<&Path>, output: Option<&Path>, dry_run: bool, json: bool) {
179 match pack_package_impl(anchor, output, dry_run) {
180 Ok(report) => {
181 if json {
182 println!(
183 "{}",
184 serde_json::to_string_pretty(&report)
185 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
186 );
187 } else {
188 print_package_pack_report(&report);
189 }
190 }
191 Err(error) => {
192 eprintln!("error: {error}");
193 process::exit(1);
194 }
195 }
196}
197
198pub fn generate_package_docs(anchor: Option<&Path>, output: Option<&Path>, check: bool) {
199 match generate_package_docs_impl(anchor, output, check) {
200 Ok(path) if check => println!("{} is up to date.", path.display()),
201 Ok(path) => println!("Wrote {}.", path.display()),
202 Err(error) => {
203 eprintln!("error: {error}");
204 process::exit(1);
205 }
206 }
207}
208
209#[allow(clippy::too_many_arguments)]
210pub fn publish_package(
211 anchor: Option<&Path>,
212 dry_run: bool,
213 remote: &str,
214 index_repo: &str,
215 index_path: &Path,
216 registry_name: Option<&str>,
217 skip_index_pr: bool,
218 registry: Option<&str>,
219 json: bool,
220) {
221 let options = PackagePublishOptions {
222 dry_run,
223 remote,
224 index_repo,
225 index_path,
226 registry_name,
227 skip_index_pr,
228 registry,
229 };
230
231 match publish_package_impl(anchor, &options) {
232 Ok(report) => {
233 if json {
234 println!(
235 "{}",
236 serde_json::to_string_pretty(&report)
237 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
238 );
239 } else {
240 if report.dry_run {
241 println!("Publish dry run to {} succeeded.", report.registry);
242 } else {
243 println!("Published {}.", report.tag);
244 }
245 println!("tag: {}", report.tag);
246 println!("sha: {}", report.sha);
247 if let Some(command) = report.tag_command.as_deref() {
248 println!("tag command: {command}");
249 }
250 if let Some(diff) = report.index_diff.as_deref() {
251 println!("\nindex diff:\n{diff}");
252 }
253 if let Some(url) = report.index_pr_url.as_deref() {
254 println!("index PR: {url}");
255 }
256 println!("artifact: {}", report.artifact_dir);
257 println!("files: {}", report.files.len());
258 }
259 }
260 Err(error) => {
261 eprintln!("error: {error}");
262 process::exit(1);
263 }
264 }
265}
266
267pub fn list_packages(json: bool) {
268 match list_packages_impl() {
269 Ok(report) if json => {
270 println!(
271 "{}",
272 serde_json::to_string_pretty(&report)
273 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
274 );
275 }
276 Ok(report) => print_package_list_report(&report),
277 Err(error) => {
278 eprintln!("error: {error}");
279 process::exit(1);
280 }
281 }
282}
283
284pub fn doctor_packages(json: bool) {
285 match doctor_packages_impl() {
286 Ok(report) if json => {
287 println!(
288 "{}",
289 serde_json::to_string_pretty(&report)
290 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
291 );
292 if !report.ok {
293 process::exit(1);
294 }
295 }
296 Ok(report) => {
297 print_package_doctor_report(&report);
298 if !report.ok {
299 process::exit(1);
300 }
301 }
302 Err(error) => {
303 eprintln!("error: {error}");
304 process::exit(1);
305 }
306 }
307}
308
309pub(crate) fn check_package_impl(
310 anchor: Option<&Path>,
311) -> Result<PackageCheckReport, PackageError> {
312 let ctx = load_manifest_context_for_anchor(anchor)?;
313 let manifest_path = ctx.manifest_path();
314 let mut errors = Vec::new();
315 let mut warnings = Vec::new();
316
317 let package = ctx.manifest.package.as_ref();
318 let name = package.and_then(|package| package.name.clone());
319 let version = package.and_then(|package| package.version.clone());
320 let package_name = required_package_string(
321 package.and_then(|package| package.name.as_deref()),
322 "[package].name",
323 &mut errors,
324 );
325 if let Some(name) = package_name {
326 if let Err(message) = validate_package_alias(name) {
327 push_error(&mut errors, "[package].name", message);
328 }
329 }
330 required_package_string(
331 package.and_then(|package| package.version.as_deref()),
332 "[package].version",
333 &mut errors,
334 );
335 required_package_string(
336 package.and_then(|package| package.description.as_deref()),
337 "[package].description",
338 &mut errors,
339 );
340 required_package_string(
341 package.and_then(|package| package.license.as_deref()),
342 "[package].license",
343 &mut errors,
344 );
345 if !ctx.dir.join("README.md").is_file() {
346 push_error(&mut errors, "README.md", "package README.md is required");
347 }
348 if !ctx.dir.join("LICENSE").is_file() && package.and_then(|p| p.license.as_deref()).is_none() {
349 push_error(
350 &mut errors,
351 "[package].license",
352 "publishable packages require a license field or LICENSE file",
353 );
354 }
355
356 validate_optional_url(
357 package.and_then(|package| package.repository.as_deref()),
358 "[package].repository",
359 &mut errors,
360 );
361 validate_docs_url(
362 &ctx.dir,
363 package.and_then(|package| package.docs_url.as_deref()),
364 &mut errors,
365 &mut warnings,
366 );
367 match package.and_then(|package| package.harn.as_deref()) {
368 Some(range) if supports_current_harn(range) => {}
369 Some(range) => push_error(
370 &mut errors,
371 "[package].harn",
372 format!(
373 "unsupported Harn version range '{range}'; include the current {} line, for example {}",
374 current_harn_line_label(),
375 current_harn_range_example()
376 ),
377 ),
378 None => push_error(
379 &mut errors,
380 "[package].harn",
381 format!(
382 "missing Harn compatibility metadata; add harn = \"{}\"",
383 current_harn_range_example()
384 ),
385 ),
386 }
387
388 validate_dependencies_for_publish(&ctx, &mut errors, &mut warnings);
389 if let Err(error) = validate_handoff_routes(&ctx.manifest.handoff_routes, &ctx.manifest) {
390 push_error(&mut errors, "handoff_routes", error.to_string());
391 }
392 let exports = validate_exports_for_publish(&ctx, &mut errors, &mut warnings);
393 let (tools, skills) = validate_package_interface_exports(&ctx, &mut errors, &mut warnings);
394
395 Ok(PackageCheckReport {
396 package_dir: ctx.dir.display().to_string(),
397 manifest_path: manifest_path.display().to_string(),
398 name,
399 version,
400 errors,
401 warnings,
402 exports,
403 tools,
404 skills,
405 })
406}
407
408pub(crate) fn list_packages_impl() -> Result<PackageListReport, PackageError> {
409 let workspace = PackageWorkspace::from_current_dir()?;
410 list_packages_in(&workspace)
411}
412
413fn list_packages_in(workspace: &PackageWorkspace) -> Result<PackageListReport, PackageError> {
414 let ctx = workspace.load_manifest_context()?;
415 let lock_path = ctx.lock_path();
416 let lock = LockFile::load(&lock_path)?;
417 let packages = lock
418 .as_ref()
419 .map(|lock| package_list_entries(&ctx, lock))
420 .unwrap_or_default();
421 Ok(PackageListReport {
422 manifest_path: ctx.manifest_path().display().to_string(),
423 lock_path: lock_path.display().to_string(),
424 lock_present: lock.is_some(),
425 dependency_count: ctx.manifest.dependencies.len(),
426 packages,
427 })
428}
429
430pub(crate) fn doctor_packages_impl() -> Result<PackageDoctorReport, PackageError> {
431 let workspace = PackageWorkspace::from_current_dir()?;
432 doctor_packages_in(&workspace)
433}
434
435fn doctor_packages_in(workspace: &PackageWorkspace) -> Result<PackageDoctorReport, PackageError> {
436 let ctx = workspace.load_manifest_context()?;
437 let lock_path = ctx.lock_path();
438 let mut diagnostics = Vec::new();
439
440 let mut root_errors = Vec::new();
441 let mut root_warnings = Vec::new();
442 if let Some(package) = ctx.manifest.package.as_ref() {
443 if let Some(name) = package.name.as_ref() {
444 if let Err(message) = validate_package_alias(name) {
445 push_error(&mut root_errors, "[package].name", message);
446 }
447 }
448 }
449 validate_package_interface_exports(&ctx, &mut root_errors, &mut root_warnings);
450 for diagnostic in root_errors {
451 diagnostics.push(package_doctor_diagnostic(
452 "error",
453 "root-package-contract",
454 format!("{}: {}", diagnostic.field, diagnostic.message),
455 Some("fix install-facing package metadata in harn.toml"),
456 ));
457 }
458 for diagnostic in root_warnings {
459 diagnostics.push(package_doctor_diagnostic(
460 "warning",
461 "root-package-contract",
462 format!("{}: {}", diagnostic.field, diagnostic.message),
463 None::<String>,
464 ));
465 }
466
467 let lock = LockFile::load(&lock_path)?;
468 if ctx.manifest.dependencies.is_empty() {
469 diagnostics.push(package_doctor_diagnostic(
470 "info",
471 "no-dependencies",
472 "manifest has no package dependencies",
473 None::<String>,
474 ));
475 } else if lock.is_none() {
476 diagnostics.push(package_doctor_diagnostic(
477 "error",
478 "missing-lockfile",
479 format!("{} is missing", lock_path.display()),
480 Some("run `harn install` to resolve dependencies and write harn.lock"),
481 ));
482 }
483
484 if let Some(lock) = lock.as_ref() {
485 if let Err(error) = validate_lock_matches_manifest(workspace, &ctx, lock) {
486 diagnostics.push(package_doctor_diagnostic(
487 "error",
488 "stale-lockfile",
489 error.to_string(),
490 Some("run `harn install` to refresh harn.lock"),
491 ));
492 }
493 for entry in &lock.packages {
494 validate_installed_package_entry(&ctx, entry, &mut diagnostics);
495 }
496 }
497
498 let packages = lock
499 .as_ref()
500 .map(|lock| package_list_entries(&ctx, lock))
501 .unwrap_or_default();
502 let ok = diagnostics
503 .iter()
504 .all(|diagnostic| diagnostic.severity != "error");
505 Ok(PackageDoctorReport {
506 ok,
507 manifest_path: ctx.manifest_path().display().to_string(),
508 lock_path: lock_path.display().to_string(),
509 diagnostics,
510 packages,
511 })
512}
513
514fn package_list_entries(ctx: &ManifestContext, lock: &LockFile) -> Vec<PackageListEntry> {
515 lock.packages
516 .iter()
517 .map(|entry| {
518 let materialized = materialized_package_exists(ctx, entry);
519 PackageListEntry {
520 name: entry.name.clone(),
521 source: entry.source.clone(),
522 package_version: entry.package_version.clone(),
523 harn_compat: entry.harn_compat.clone(),
524 provenance: entry.provenance.clone(),
525 materialized,
526 integrity: package_integrity_status(ctx, entry),
527 exports: entry.exports.clone(),
528 permissions: entry.permissions.clone(),
529 host_requirements: entry.host_requirements.clone(),
530 }
531 })
532 .collect()
533}
534
535fn materialized_package_path(ctx: &ManifestContext, entry: &LockEntry) -> PathBuf {
536 let packages_dir = ctx.packages_dir();
537 let dir = packages_dir.join(&entry.name);
538 if dir.exists() {
539 return dir;
540 }
541 packages_dir.join(format!("{}.harn", entry.name))
542}
543
544fn materialized_package_exists(ctx: &ManifestContext, entry: &LockEntry) -> bool {
545 materialized_package_path(ctx, entry).exists()
546}
547
548fn package_integrity_status(ctx: &ManifestContext, entry: &LockEntry) -> String {
549 if !materialized_package_exists(ctx, entry) {
550 return "missing".to_string();
551 }
552 let Some(expected) = entry.content_hash.as_deref() else {
553 return "not_checked".to_string();
554 };
555 let path = materialized_package_path(ctx, entry);
556 if path.is_dir() && materialized_hash_matches(&path, expected) {
557 "ok".to_string()
558 } else {
559 "mismatch".to_string()
560 }
561}
562
563fn validate_installed_package_entry(
564 ctx: &ManifestContext,
565 entry: &LockEntry,
566 diagnostics: &mut Vec<PackageDoctorDiagnostic>,
567) {
568 let materialized_path = materialized_package_path(ctx, entry);
569 if !materialized_path.exists() {
570 diagnostics.push(package_doctor_diagnostic(
571 "error",
572 "package-not-materialized",
573 format!(
574 "package {} is locked but missing from {}",
575 entry.name,
576 ctx.packages_dir().display()
577 ),
578 Some("run `harn install` to materialize locked packages"),
579 ));
580 return;
581 }
582
583 if package_integrity_status(ctx, entry) == "mismatch" {
584 diagnostics.push(package_doctor_diagnostic(
585 "error",
586 "content-hash-mismatch",
587 format!(
588 "package {} does not match its locked content hash",
589 entry.name
590 ),
591 Some(
592 "run `harn install --refetch {alias}` or inspect local tampering"
593 .replace("{alias}", &entry.name),
594 ),
595 ));
596 }
597
598 for requirement in &entry.host_requirements {
599 if !host_requirement_satisfied(&ctx.manifest.check, requirement) {
600 diagnostics.push(package_doctor_diagnostic(
601 "error",
602 "missing-host-capability",
603 format!(
604 "package {} requires host capability {requirement}, but harn.toml does not declare it",
605 entry.name
606 ),
607 Some("add the capability under [check.host_capabilities] or preflight_allow after the host implements it"),
608 ));
609 }
610 }
611
612 if materialized_path.is_dir() {
613 match read_package_manifest_from_dir(&materialized_path) {
614 Ok(Some(manifest)) => {
615 let installed_ctx = ManifestContext {
616 manifest,
617 dir: materialized_path,
618 };
619 let mut errors = Vec::new();
620 let mut warnings = Vec::new();
621 validate_package_interface_exports(&installed_ctx, &mut errors, &mut warnings);
622 for diagnostic in errors {
623 diagnostics.push(package_doctor_diagnostic(
624 "error",
625 "installed-package-export",
626 format!("{}: {}", diagnostic.field, diagnostic.message),
627 Some(format!("fix package {} and reinstall it", entry.name)),
628 ));
629 }
630 for diagnostic in warnings {
631 diagnostics.push(package_doctor_diagnostic(
632 "warning",
633 "installed-package-export-warning",
634 format!("{}: {}", diagnostic.field, diagnostic.message),
635 None::<String>,
636 ));
637 }
638 }
639 Ok(None) => {}
640 Err(error) => diagnostics.push(package_doctor_diagnostic(
641 "error",
642 "installed-manifest-unreadable",
643 format!("failed to read package {} manifest: {error}", entry.name),
644 Some("repair the package source and run `harn install`"),
645 )),
646 }
647 }
648}
649
650fn host_requirement_satisfied(check: &CheckConfig, requirement: &str) -> bool {
651 if check.preflight_allow.iter().any(|allow| {
652 allow == "*"
653 || allow == requirement
654 || requirement
655 .strip_prefix(allow.trim_end_matches(".*"))
656 .is_some_and(|rest| allow.ends_with(".*") && rest.starts_with('.'))
657 || requirement
658 .split_once('.')
659 .is_some_and(|(capability, _)| allow == capability)
660 }) {
661 return true;
662 }
663 let Some((capability, operation)) = requirement.split_once('.') else {
664 return false;
665 };
666 check
667 .host_capabilities
668 .get(capability)
669 .is_some_and(|ops| ops.iter().any(|op| op == "*" || op == operation))
670}
671
672fn package_doctor_diagnostic(
673 severity: impl Into<String>,
674 code: impl Into<String>,
675 message: impl Into<String>,
676 help: Option<impl Into<String>>,
677) -> PackageDoctorDiagnostic {
678 PackageDoctorDiagnostic {
679 severity: severity.into(),
680 code: code.into(),
681 message: message.into(),
682 help: help.map(Into::into),
683 }
684}
685
686pub(crate) fn pack_package_impl(
687 anchor: Option<&Path>,
688 output: Option<&Path>,
689 dry_run: bool,
690) -> Result<PackagePackReport, PackageError> {
691 let report = check_package_impl(anchor)?;
692 fail_if_package_errors(&report)?;
693 let ctx = load_manifest_context_for_anchor(anchor)?;
694 let files = collect_package_files(&ctx.dir)?;
695 let artifact_dir = output
696 .map(Path::to_path_buf)
697 .unwrap_or_else(|| default_artifact_dir(&ctx, &report));
698
699 if !dry_run {
700 if artifact_dir.exists() {
701 return Err(
702 format!("artifact output {} already exists", artifact_dir.display()).into(),
703 );
704 }
705 fs::create_dir_all(&artifact_dir)
706 .map_err(|error| format!("failed to create {}: {error}", artifact_dir.display()))?;
707 for rel in &files {
708 let src = ctx.dir.join(rel);
709 let dst = artifact_dir.join(rel);
710 if let Some(parent) = dst.parent() {
711 fs::create_dir_all(parent)
712 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
713 }
714 fs::copy(&src, &dst)
715 .map_err(|error| format!("failed to copy {}: {error}", src.display()))?;
716 }
717 let manifest_path = artifact_dir.join(".harn-package-manifest.json");
718 let manifest_body = serde_json::to_string_pretty(&report)
719 .map_err(|error| format!("failed to render package manifest: {error}"))?
720 + "\n";
721 harn_vm::atomic_io::atomic_write(&manifest_path, manifest_body.as_bytes())
722 .map_err(|error| format!("failed to write {}: {error}", manifest_path.display()))?;
723 }
724
725 Ok(PackagePackReport {
726 package_dir: ctx.dir.display().to_string(),
727 artifact_dir: artifact_dir.display().to_string(),
728 dry_run,
729 files,
730 check: report,
731 })
732}
733
734pub(crate) fn generate_package_docs_impl(
735 anchor: Option<&Path>,
736 output: Option<&Path>,
737 check: bool,
738) -> Result<PathBuf, PackageError> {
739 let report = check_package_impl(anchor)?;
740 let ctx = load_manifest_context_for_anchor(anchor)?;
741 let output_path = output
742 .map(Path::to_path_buf)
743 .unwrap_or_else(|| ctx.dir.join("docs").join("api.md"));
744 let rendered = render_package_api_docs(&report);
745 if check {
746 let existing = fs::read_to_string(&output_path)
747 .map_err(|error| format!("failed to read {}: {error}", output_path.display()))?;
748 if normalize_newlines(&existing) != normalize_newlines(&rendered) {
749 return Err(format!(
750 "{} is stale; run `harn package docs`",
751 output_path.display()
752 )
753 .into());
754 }
755 return Ok(output_path);
756 }
757 harn_vm::atomic_io::atomic_write(&output_path, rendered.as_bytes())
758 .map_err(|error| format!("failed to write {}: {error}", output_path.display()))?;
759 Ok(output_path)
760}
761
762pub(crate) fn publish_package_impl(
763 anchor: Option<&Path>,
764 options: &PackagePublishOptions<'_>,
765) -> Result<PackagePublishReport, PackageError> {
766 let registry = options
767 .registry
768 .map(str::trim)
769 .filter(|value| !value.is_empty())
770 .map(ToOwned::to_owned)
771 .unwrap_or_else(|| {
772 format!(
773 "{}/{}",
774 options.index_repo.trim(),
775 normalized_relative_path(options.index_path)
776 )
777 });
778 let index_content = if options.skip_index_pr {
779 String::new()
780 } else {
781 fetch_package_index_from_github(options.index_repo, options.index_path)?
782 };
783 let mut plan = prepare_publish_plan(anchor, options, index_content, ®istry)?;
784 if !options.dry_run && !options.skip_index_pr {
785 ensure_github_repo_writeable(options.index_repo)?;
786 }
787 let index_pr_url = if options.dry_run {
788 None
789 } else {
790 execute_publish_plan(&mut plan, options.skip_index_pr)?
791 };
792
793 Ok(PackagePublishReport {
794 dry_run: options.dry_run,
795 registry,
796 artifact_dir: plan.pack.artifact_dir,
797 files: plan.pack.files,
798 tag: plan.tag,
799 sha: plan.sha,
800 remote: plan.remote,
801 index_repo: plan.index_repo,
802 index_path: normalized_relative_path(&plan.index_path),
803 index_pr_url,
804 tag_command: Some(plan.tag_command),
805 index_diff: if options.skip_index_pr {
806 None
807 } else {
808 Some(plan.index_diff)
809 },
810 check: plan.pack.check,
811 })
812}
813
814fn prepare_publish_plan(
815 anchor: Option<&Path>,
816 options: &PackagePublishOptions<'_>,
817 index_content: String,
818 registry: &str,
819) -> Result<PackagePublishPlan, PackageError> {
820 let pack = pack_package_impl(anchor, None, true)?;
821 let ctx = load_manifest_context_for_anchor(anchor)?;
822 let package_info = ctx
823 .manifest
824 .package
825 .as_ref()
826 .ok_or_else(|| PackageError::Ops("[package] metadata is required".to_string()))?;
827 let package_name = pack
828 .check
829 .name
830 .clone()
831 .ok_or_else(|| PackageError::Ops("[package].name is required".to_string()))?;
832 let version = pack
833 .check
834 .version
835 .clone()
836 .ok_or_else(|| PackageError::Ops("[package].version is required".to_string()))?;
837 let registry_name = options
838 .registry_name
839 .map(str::trim)
840 .filter(|name| !name.is_empty())
841 .unwrap_or(&package_name)
842 .to_string();
843 if !is_valid_registry_package_name(®istry_name) {
844 return Err(PackageError::Validation(format!(
845 "invalid registry package name '{registry_name}'; use names like @burin/notion-sdk or acme-lib"
846 )));
847 }
848
849 let repo_root = git_output(&ctx.dir, ["rev-parse", "--show-toplevel"])?;
850 let repo_root = PathBuf::from(repo_root.trim());
851 ensure_git_worktree_clean(&repo_root)?;
852 let sha = git_output(&repo_root, ["rev-parse", "HEAD"])?
853 .trim()
854 .to_string();
855 let remote = options.remote.trim();
856 if remote.is_empty() {
857 return Err(PackageError::Ops("--remote cannot be empty".to_string()));
858 }
859 let remote_url = git_output(&repo_root, ["remote", "get-url", remote])?
860 .trim()
861 .to_string();
862 let git = normalize_git_url(&remote_url)?;
863 let tag = format!("v{version}");
864 ensure_tag_available(&repo_root, remote, &tag)?;
865 ensure_changelog_entry(&ctx.dir.join("CHANGELOG.md"), &version)?;
866
867 let (updated_index_content, index_diff) = if options.skip_index_pr {
868 (index_content.clone(), String::new())
869 } else {
870 let entry = render_registry_version_entry(&version, &git, &tag, &sha, &package_name)?;
871 let updated = add_registry_version_entry(
872 &index_content,
873 package_info,
874 &pack.check,
875 ®istry_name,
876 &entry,
877 &version,
878 &git,
879 )?;
880 parse_package_registry_index(registry, &updated)?;
881 let diff = render_unified_diff(
882 &index_content,
883 &updated,
884 &normalized_relative_path(options.index_path),
885 )?;
886 (updated, diff)
887 };
888
889 Ok(PackagePublishPlan {
890 repo_root: repo_root.clone(),
891 package_name,
892 registry_name,
893 version,
894 tag: tag.clone(),
895 sha,
896 git,
897 remote: remote.to_string(),
898 index_repo: options.index_repo.trim().to_string(),
899 index_path: options.index_path.to_path_buf(),
900 updated_index_content,
901 index_diff,
902 tag_command: format!(
903 "git -C {} tag {tag} && git -C {} push {remote} refs/tags/{tag}",
904 shell_quote_path(&repo_root),
905 shell_quote_path(&repo_root)
906 ),
907 pack,
908 })
909}
910
911fn execute_publish_plan(
912 plan: &mut PackagePublishPlan,
913 skip_index_pr: bool,
914) -> Result<Option<String>, PackageError> {
915 run_git_checked(&plan.repo_root, ["tag", plan.tag.as_str()])?;
916 run_git_checked(
917 &plan.repo_root,
918 [
919 "push",
920 plan.remote.as_str(),
921 &format!("refs/tags/{}", plan.tag),
922 ],
923 )?;
924 if skip_index_pr {
925 return Ok(None);
926 }
927 create_index_pull_request(plan).map(Some)
928}
929
930fn create_index_pull_request(plan: &PackagePublishPlan) -> Result<String, PackageError> {
931 let temp = tempfile::tempdir()
932 .map_err(|error| PackageError::Ops(format!("failed to create temp dir: {error}")))?;
933 let checkout = temp.path().join("index");
934 let base_branch = github_default_branch(&plan.index_repo)?;
935 run_command_checked(
936 Path::new("."),
937 "gh",
938 [
939 "repo",
940 "clone",
941 plan.index_repo.as_str(),
942 checkout.to_string_lossy().as_ref(),
943 "--",
944 "--depth",
945 "1",
946 "--branch",
947 base_branch.as_str(),
948 ],
949 )?;
950 let branch = format!(
951 "harn-publish/{}-{}",
952 sanitize_branch_segment(&plan.package_name),
953 sanitize_branch_segment(&plan.version)
954 );
955 run_git_checked(&checkout, ["switch", "-c", branch.as_str()])?;
956 let index_path = checkout.join(&plan.index_path);
957 if let Some(parent) = index_path.parent() {
958 fs::create_dir_all(parent)
959 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
960 }
961 fs::write(&index_path, &plan.updated_index_content)
962 .map_err(|error| format!("failed to write {}: {error}", index_path.display()))?;
963 run_git_checked(
964 &checkout,
965 ["add", normalized_relative_path(&plan.index_path).as_str()],
966 )?;
967 run_git_checked(
968 &checkout,
969 [
970 "commit",
971 "-m",
972 &format!(
973 "Add {} {} to package index",
974 plan.registry_name, plan.version
975 ),
976 ],
977 )?;
978 run_git_checked(&checkout, ["push", "-u", "origin", branch.as_str()])?;
979 let body = format!(
980 "Adds `{}` version `{}` to the Harn package index.\n\nSource tag: `{}`\nSource SHA: `{}`\nSource git: `{}`\n",
981 plan.registry_name, plan.version, plan.tag, plan.sha, plan.git
982 );
983 let body_path = temp.path().join("pr-body.md");
984 fs::write(&body_path, body)
985 .map_err(|error| format!("failed to write {}: {error}", body_path.display()))?;
986 let output = run_command_output(
987 Path::new("."),
988 "gh",
989 [
990 "pr",
991 "create",
992 "--repo",
993 plan.index_repo.as_str(),
994 "--base",
995 base_branch.as_str(),
996 "--head",
997 branch.as_str(),
998 "--title",
999 &format!(
1000 "Add {} {} to package index",
1001 plan.registry_name, plan.version
1002 ),
1003 "--body-file",
1004 body_path.to_string_lossy().as_ref(),
1005 ],
1006 )?;
1007 Ok(output.trim().to_string())
1008}
1009
1010fn github_default_branch(index_repo: &str) -> Result<String, PackageError> {
1011 let branch = run_command_output(
1012 Path::new("."),
1013 "gh",
1014 [
1015 "repo",
1016 "view",
1017 index_repo.trim(),
1018 "--json",
1019 "defaultBranchRef",
1020 "--jq",
1021 ".defaultBranchRef.name",
1022 ],
1023 )?;
1024 let branch = branch.trim();
1025 if branch.is_empty() {
1026 Err(PackageError::Registry(format!(
1027 "failed to resolve default branch for {index_repo}"
1028 )))
1029 } else {
1030 Ok(branch.to_string())
1031 }
1032}
1033
1034fn fetch_package_index_from_github(
1035 index_repo: &str,
1036 index_path: &Path,
1037) -> Result<String, PackageError> {
1038 ensure_gh_available()?;
1039 let api_path = format!(
1040 "repos/{}/contents/{}",
1041 index_repo.trim(),
1042 normalized_relative_path(index_path)
1043 );
1044 let content = run_command_output(
1045 Path::new("."),
1046 "gh",
1047 ["api", api_path.as_str(), "--jq", ".content"],
1048 )?;
1049 let encoded = content.replace(['\n', '\r'], "");
1050 let bytes = base64::engine::general_purpose::STANDARD
1051 .decode(encoded.as_bytes())
1052 .map_err(|error| {
1053 PackageError::Registry(format!(
1054 "failed to decode package index from {index_repo}: {}: {error}",
1055 index_path.display()
1056 ))
1057 })?;
1058 String::from_utf8(bytes).map_err(|error| {
1059 PackageError::Registry(format!(
1060 "package index {} in {index_repo} is not UTF-8: {error}",
1061 index_path.display()
1062 ))
1063 })
1064}
1065
1066fn ensure_gh_available() -> Result<(), PackageError> {
1067 which::which("gh").map(|_| ()).map_err(|_| {
1068 PackageError::Registry(
1069 "gh is required to read or update the package index but was not found in PATH"
1070 .to_string(),
1071 )
1072 })
1073}
1074
1075fn ensure_github_repo_writeable(index_repo: &str) -> Result<(), PackageError> {
1076 let permission = run_command_output(
1077 Path::new("."),
1078 "gh",
1079 [
1080 "repo",
1081 "view",
1082 index_repo.trim(),
1083 "--json",
1084 "viewerPermission",
1085 "--jq",
1086 ".viewerPermission",
1087 ],
1088 )?;
1089 let permission = permission.trim();
1090 if matches!(permission, "ADMIN" | "MAINTAIN" | "WRITE") {
1091 Ok(())
1092 } else {
1093 Err(PackageError::Registry(format!(
1094 "current gh auth has {permission} permission on {index_repo}; WRITE, MAINTAIN, or ADMIN is required to open the package-index PR"
1095 )))
1096 }
1097}
1098
1099fn ensure_git_worktree_clean(repo: &Path) -> Result<(), PackageError> {
1100 let status = git_output(repo, ["status", "--porcelain"])?;
1101 if status.trim().is_empty() {
1102 Ok(())
1103 } else {
1104 Err(PackageError::Ops(format!(
1105 "working tree must be clean before publishing:\n{}",
1106 status.trim_end()
1107 )))
1108 }
1109}
1110
1111fn ensure_tag_available(repo: &Path, remote: &str, tag: &str) -> Result<(), PackageError> {
1112 if git_status(
1113 repo,
1114 [
1115 "rev-parse",
1116 "--verify",
1117 "--quiet",
1118 &format!("refs/tags/{tag}"),
1119 ],
1120 )?
1121 .success()
1122 {
1123 return Err(PackageError::Ops(format!(
1124 "git tag {tag} already exists locally"
1125 )));
1126 }
1127 let status = git_status(
1128 repo,
1129 [
1130 "ls-remote",
1131 "--exit-code",
1132 "--tags",
1133 remote,
1134 &format!("refs/tags/{tag}"),
1135 ],
1136 )?;
1137 if status.success() {
1138 return Err(PackageError::Ops(format!(
1139 "git tag {tag} already exists on remote {remote}"
1140 )));
1141 }
1142 if status.code() == Some(2) {
1143 return Ok(());
1144 }
1145 Err(PackageError::Ops(format!(
1146 "failed to check whether tag {tag} exists on remote {remote}"
1147 )))
1148}
1149
1150fn ensure_changelog_entry(path: &Path, version: &str) -> Result<(), PackageError> {
1151 let content = fs::read_to_string(path)
1152 .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
1153 if changelog_has_nonempty_entry(&content, version) {
1154 Ok(())
1155 } else {
1156 Err(PackageError::Validation(format!(
1157 "{} must contain a non-empty entry for version {version}",
1158 path.display()
1159 )))
1160 }
1161}
1162
1163fn changelog_has_nonempty_entry(content: &str, version: &str) -> bool {
1164 let escaped = regex::escape(version);
1165 let heading = Regex::new(&format!(
1166 r"(?m)^#{{1,6}}\s+(?:\[?v?{}\]?)(?:\s|$|[-(])",
1167 escaped
1168 ))
1169 .expect("valid changelog heading regex");
1170 let Some(found) = heading.find(content) else {
1171 return false;
1172 };
1173 let rest = &content[found.end()..];
1174 let entry = rest
1175 .lines()
1176 .take_while(|line| !line.trim_start().starts_with('#'))
1177 .map(str::trim)
1178 .filter(|line| !line.is_empty() && !line.starts_with("<!--"))
1179 .collect::<Vec<_>>();
1180 !entry.is_empty()
1181}
1182
1183fn add_registry_version_entry(
1184 content: &str,
1185 package_info: &PackageInfo,
1186 report: &PackageCheckReport,
1187 registry_name: &str,
1188 version_entry: &str,
1189 version: &str,
1190 git: &str,
1191) -> Result<String, PackageError> {
1192 let snapshot = parse_publish_index_snapshot(content)?;
1193 if let Some(package) = snapshot
1194 .packages
1195 .iter()
1196 .find(|package| package.name == registry_name)
1197 {
1198 if package
1199 .versions
1200 .iter()
1201 .any(|entry| entry.version == version)
1202 {
1203 return Err(PackageError::Registry(format!(
1204 "package index already contains {registry_name}@{version}"
1205 )));
1206 }
1207 return insert_version_entry(content, registry_name, version_entry);
1208 }
1209
1210 let mut updated = content.trim_end().to_string();
1211 updated.push_str("\n\n");
1212 updated.push_str(&render_registry_package_block(
1213 package_info,
1214 report,
1215 registry_name,
1216 git,
1217 version_entry,
1218 )?);
1219 Ok(updated)
1220}
1221
1222fn insert_version_entry(
1223 content: &str,
1224 registry_name: &str,
1225 version_entry: &str,
1226) -> Result<String, PackageError> {
1227 let starts = package_block_offsets(content);
1228 for (idx, start) in starts.iter().enumerate() {
1229 let end = starts.get(idx + 1).copied().unwrap_or(content.len());
1230 let block = &content[*start..end];
1231 if block_has_registry_name(block, registry_name) {
1232 let mut updated = String::with_capacity(content.len() + version_entry.len() + 2);
1233 updated.push_str(content[..end].trim_end());
1234 updated.push_str("\n\n");
1235 updated.push_str(version_entry.trim_end());
1236 updated.push('\n');
1237 updated.push_str(&content[end..]);
1238 return Ok(updated);
1239 }
1240 }
1241 Err(PackageError::Registry(format!(
1242 "failed to locate package index block for {registry_name}"
1243 )))
1244}
1245
1246fn package_block_offsets(content: &str) -> Vec<usize> {
1247 let mut offsets = Vec::new();
1248 let mut cursor = 0;
1249 for line in content.split_inclusive('\n') {
1250 if line.trim() == "[[package]]" {
1251 offsets.push(cursor);
1252 }
1253 cursor += line.len();
1254 }
1255 if cursor < content.len() && content[cursor..].trim() == "[[package]]" {
1256 offsets.push(cursor);
1257 }
1258 offsets
1259}
1260
1261fn block_has_registry_name(block: &str, registry_name: &str) -> bool {
1262 let literal = match toml_string_literal(registry_name) {
1263 Ok(literal) => literal,
1264 Err(_) => return false,
1265 };
1266 block.lines().any(|line| {
1267 let line = line.trim();
1268 line.strip_prefix("name")
1269 .and_then(|rest| rest.trim_start().strip_prefix('='))
1270 .is_some_and(|value| value.trim() == literal)
1271 })
1272}
1273
1274#[derive(Debug, Deserialize)]
1275struct PublishIndexSnapshot {
1276 #[serde(default, rename = "package")]
1277 packages: Vec<PublishIndexPackageSnapshot>,
1278}
1279
1280#[derive(Debug, Deserialize)]
1281struct PublishIndexPackageSnapshot {
1282 name: String,
1283 #[serde(default, rename = "version")]
1284 versions: Vec<PublishIndexVersionSnapshot>,
1285}
1286
1287#[derive(Debug, Deserialize)]
1288struct PublishIndexVersionSnapshot {
1289 version: String,
1290}
1291
1292fn parse_publish_index_snapshot(content: &str) -> Result<PublishIndexSnapshot, PackageError> {
1293 toml::from_str(content)
1294 .map_err(|error| PackageError::Registry(format!("failed to parse package index: {error}")))
1295}
1296
1297fn render_registry_package_block(
1298 package_info: &PackageInfo,
1299 report: &PackageCheckReport,
1300 registry_name: &str,
1301 git: &str,
1302 version_entry: &str,
1303) -> Result<String, PackageError> {
1304 let mut out = String::new();
1305 out.push_str("[[package]]\n");
1306 push_toml_string_field(&mut out, "name", registry_name)?;
1307 if let Some(description) = package_info.description.as_deref() {
1308 push_toml_string_field(&mut out, "description", description)?;
1309 }
1310 push_toml_string_field(
1311 &mut out,
1312 "repository",
1313 package_info.repository.as_deref().unwrap_or(git),
1314 )?;
1315 if let Some(license) = package_info.license.as_deref() {
1316 push_toml_string_field(&mut out, "license", license)?;
1317 }
1318 if let Some(harn) = package_info.harn.as_deref() {
1319 push_toml_string_field(&mut out, "harn", harn)?;
1320 }
1321 if !report.exports.is_empty() {
1322 let exports = report
1323 .exports
1324 .iter()
1325 .map(|export| toml_string_literal(&export.name))
1326 .collect::<Result<Vec<_>, _>>()?
1327 .join(", ");
1328 out.push_str(&format!("exports = [{exports}]\n"));
1329 }
1330 if let Some(docs_url) = package_info.docs_url.as_deref() {
1331 push_toml_string_field(&mut out, "docs_url", docs_url)?;
1332 }
1333 push_toml_string_field(&mut out, "provenance", git)?;
1334 out.push('\n');
1335 out.push_str(version_entry.trim_end());
1336 out.push('\n');
1337 Ok(out)
1338}
1339
1340fn render_registry_version_entry(
1341 version: &str,
1342 git: &str,
1343 tag: &str,
1344 sha: &str,
1345 package_name: &str,
1346) -> Result<String, PackageError> {
1347 let provenance =
1348 github_tag_url(git, tag).unwrap_or_else(|| format!("{git}/releases/tag/{tag}"));
1349 let mut out = String::new();
1350 out.push_str("[[package.version]]\n");
1351 push_toml_string_field(&mut out, "version", version)?;
1352 push_toml_string_field(&mut out, "git", git)?;
1353 push_toml_string_field(&mut out, "rev", sha)?;
1354 push_toml_string_field(&mut out, "tag", tag)?;
1355 push_toml_string_field(&mut out, "sha", sha)?;
1356 push_toml_string_field(&mut out, "package", package_name)?;
1357 push_toml_string_field(&mut out, "provenance", &provenance)?;
1358 Ok(out)
1359}
1360
1361fn github_tag_url(git: &str, tag: &str) -> Option<String> {
1362 let url = Url::parse(git).ok()?;
1363 let host = url.host_str()?;
1364 if host != "github.com" {
1365 return None;
1366 }
1367 let path = url.path().trim_matches('/');
1368 let mut segments = path.split('/');
1369 let owner = segments.next()?;
1370 let repo = segments.next()?;
1371 Some(format!(
1372 "https://github.com/{owner}/{repo}/releases/tag/{tag}"
1373 ))
1374}
1375
1376fn push_toml_string_field(out: &mut String, key: &str, value: &str) -> Result<(), PackageError> {
1377 out.push_str(key);
1378 out.push_str(" = ");
1379 out.push_str(&toml_string_literal(value)?);
1380 out.push('\n');
1381 Ok(())
1382}
1383
1384fn toml_string_literal(value: &str) -> Result<String, PackageError> {
1385 let mut out = String::with_capacity(value.len() + 2);
1386 out.push('"');
1387 for ch in value.chars() {
1388 match ch {
1389 '"' => out.push_str("\\\""),
1390 '\\' => out.push_str("\\\\"),
1391 '\n' => out.push_str("\\n"),
1392 '\r' => out.push_str("\\r"),
1393 '\t' => out.push_str("\\t"),
1394 ch if ch.is_control() => {
1395 out.push_str(&format!("\\u{:04X}", ch as u32));
1396 }
1397 ch => out.push(ch),
1398 }
1399 }
1400 out.push('"');
1401 Ok(out)
1402}
1403
1404fn render_unified_diff(old: &str, new: &str, label: &str) -> Result<String, PackageError> {
1405 let temp = tempfile::tempdir()
1406 .map_err(|error| PackageError::Ops(format!("failed to create temp dir: {error}")))?;
1407 let old_path = temp.path().join("old");
1408 let new_path = temp.path().join("new");
1409 fs::write(&old_path, old).map_err(|error| format!("failed to write diff input: {error}"))?;
1410 fs::write(&new_path, new).map_err(|error| format!("failed to write diff input: {error}"))?;
1411 let output = process::Command::new("git")
1412 .args(["diff", "--no-index", "--"])
1413 .arg(&old_path)
1414 .arg(&new_path)
1415 .output()
1416 .map_err(|error| {
1417 PackageError::Ops(format!("failed to render package-index diff: {error}"))
1418 })?;
1419 if !output.status.success() && output.status.code() != Some(1) {
1420 return Err(PackageError::Ops(format!(
1421 "failed to render package-index diff: {}",
1422 String::from_utf8_lossy(&output.stderr)
1423 )));
1424 }
1425 let mut diff = String::from_utf8_lossy(&output.stdout).into_owned();
1426 let old_display = old_path.display().to_string();
1427 let new_display = new_path.display().to_string();
1428 diff = diff.replace(&format!("--- {old_display}"), &format!("--- a/{label}"));
1429 diff = diff.replace(&format!("+++ {new_display}"), &format!("+++ b/{label}"));
1430 Ok(diff)
1431}
1432
1433fn git_output<const N: usize>(repo: &Path, args: [&str; N]) -> Result<String, PackageError> {
1434 run_command_output(repo, "git", args)
1435}
1436
1437fn git_status<const N: usize>(
1438 repo: &Path,
1439 args: [&str; N],
1440) -> Result<process::ExitStatus, PackageError> {
1441 process::Command::new("git")
1442 .current_dir(repo)
1443 .args(args)
1444 .env_remove("GIT_DIR")
1445 .env_remove("GIT_WORK_TREE")
1446 .env_remove("GIT_INDEX_FILE")
1447 .output()
1448 .map(|output| output.status)
1449 .map_err(|error| PackageError::Ops(format!("failed to run git: {error}")))
1450}
1451
1452fn run_git_checked<const N: usize>(repo: &Path, args: [&str; N]) -> Result<(), PackageError> {
1453 run_command_checked(repo, "git", args)
1454}
1455
1456fn run_command_checked<const N: usize>(
1457 cwd: &Path,
1458 program: &str,
1459 args: [&str; N],
1460) -> Result<(), PackageError> {
1461 run_command_output(cwd, program, args).map(|_| ())
1462}
1463
1464fn run_command_output<const N: usize>(
1465 cwd: &Path,
1466 program: &str,
1467 args: [&str; N],
1468) -> Result<String, PackageError> {
1469 let output = process::Command::new(program)
1470 .current_dir(cwd)
1471 .args(args)
1472 .env_remove("GIT_DIR")
1473 .env_remove("GIT_WORK_TREE")
1474 .env_remove("GIT_INDEX_FILE")
1475 .output()
1476 .map_err(|error| PackageError::Ops(format!("failed to run {program}: {error}")))?;
1477 if !output.status.success() {
1478 return Err(PackageError::Ops(format!(
1479 "{} failed: {}",
1480 program,
1481 String::from_utf8_lossy(&output.stderr).trim_end()
1482 )));
1483 }
1484 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1485}
1486
1487fn sanitize_branch_segment(value: &str) -> String {
1488 value
1489 .chars()
1490 .map(|ch| {
1491 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
1492 ch
1493 } else {
1494 '-'
1495 }
1496 })
1497 .collect()
1498}
1499
1500fn shell_quote_path(path: &Path) -> String {
1501 let raw = path.display().to_string();
1502 if raw
1503 .bytes()
1504 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'-' | b'_'))
1505 {
1506 raw
1507 } else {
1508 format!("'{}'", raw.replace('\'', "'\\''"))
1509 }
1510}
1511
1512pub(crate) fn load_manifest_context_for_anchor(
1513 anchor: Option<&Path>,
1514) -> Result<ManifestContext, PackageError> {
1515 let anchor = anchor
1516 .map(Path::to_path_buf)
1517 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1518 let manifest_path = if anchor.is_dir() {
1519 anchor.join(MANIFEST)
1520 } else if anchor.file_name() == Some(OsStr::new(MANIFEST)) {
1521 anchor.clone()
1522 } else {
1523 let (_, dir) = find_nearest_manifest(&anchor)
1524 .ok_or_else(|| format!("no {MANIFEST} found from {}", anchor.display()))?;
1525 dir.join(MANIFEST)
1526 };
1527 let manifest = read_manifest_from_path(&manifest_path)?;
1528 let dir = manifest_path
1529 .parent()
1530 .map(Path::to_path_buf)
1531 .unwrap_or_else(|| PathBuf::from("."));
1532 Ok(ManifestContext { manifest, dir })
1533}
1534
1535pub(crate) fn required_package_string<'a>(
1536 value: Option<&'a str>,
1537 field: &str,
1538 errors: &mut Vec<PackageCheckDiagnostic>,
1539) -> Option<&'a str> {
1540 match value.map(str::trim).filter(|value| !value.is_empty()) {
1541 Some(value) => Some(value),
1542 None => {
1543 push_error(errors, field, format!("missing required {field}"));
1544 None
1545 }
1546 }
1547}
1548
1549pub(crate) fn push_error(
1550 diagnostics: &mut Vec<PackageCheckDiagnostic>,
1551 field: impl Into<String>,
1552 message: impl Into<String>,
1553) {
1554 diagnostics.push(PackageCheckDiagnostic {
1555 field: field.into(),
1556 message: message.into(),
1557 });
1558}
1559
1560pub(crate) fn push_warning(
1561 diagnostics: &mut Vec<PackageCheckDiagnostic>,
1562 field: impl Into<String>,
1563 message: impl Into<String>,
1564) {
1565 push_error(diagnostics, field, message);
1566}
1567
1568pub(crate) fn validate_optional_url(
1569 value: Option<&str>,
1570 field: &str,
1571 errors: &mut Vec<PackageCheckDiagnostic>,
1572) {
1573 let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
1574 push_error(errors, field, format!("missing required {field}"));
1575 return;
1576 };
1577 if Url::parse(value).is_err() {
1578 push_error(errors, field, format!("{field} must be an absolute URL"));
1579 }
1580}
1581
1582pub(crate) fn validate_docs_url(
1583 root: &Path,
1584 value: Option<&str>,
1585 errors: &mut Vec<PackageCheckDiagnostic>,
1586 warnings: &mut Vec<PackageCheckDiagnostic>,
1587) {
1588 let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
1589 push_warning(
1590 warnings,
1591 "[package].docs_url",
1592 "missing docs_url; `harn package docs` defaults to docs/api.md",
1593 );
1594 return;
1595 };
1596 if Url::parse(value).is_ok() {
1597 return;
1598 }
1599 let path = PathBuf::from(value);
1600 let path = if path.is_absolute() {
1601 path
1602 } else {
1603 root.join(path)
1604 };
1605 if !path.exists() {
1606 push_error(
1607 errors,
1608 "[package].docs_url",
1609 format!("docs_url path {} does not exist", path.display()),
1610 );
1611 }
1612}
1613
1614pub(crate) fn validate_dependencies_for_publish(
1615 ctx: &ManifestContext,
1616 errors: &mut Vec<PackageCheckDiagnostic>,
1617 warnings: &mut Vec<PackageCheckDiagnostic>,
1618) {
1619 let mut aliases = BTreeSet::new();
1620 for (alias, dependency) in &ctx.manifest.dependencies {
1621 let field = format!("[dependencies].{alias}");
1622 if let Err(message) = validate_package_alias(alias) {
1623 push_error(errors, &field, message);
1624 }
1625 if !aliases.insert(alias) {
1626 push_error(errors, &field, "duplicate dependency alias");
1627 }
1628 match dependency {
1629 Dependency::Path(path) => push_error(
1630 errors,
1631 &field,
1632 format!("path-only dependency '{path}' is not publishable; pin a git tag, git rev, or registry version"),
1633 ),
1634 Dependency::Table(table) => {
1635 if table.version.is_some()
1636 && (table.git.is_some()
1637 || table.path.is_some()
1638 || table.rev.is_some()
1639 || table.tag.is_some()
1640 || table.branch.is_some())
1641 {
1642 push_error(
1643 errors,
1644 &field,
1645 "version dependencies resolve through the registry; do not combine version with git, path, tag, rev, or branch",
1646 );
1647 }
1648 if table.path.is_some() {
1649 push_error(
1650 errors,
1651 &field,
1652 "path dependencies are not publishable; pin a git tag, git rev, or registry version",
1653 );
1654 }
1655 if table.git.is_none() && table.path.is_none() && table.version.is_none() {
1656 push_error(
1657 errors,
1658 &field,
1659 "dependency must specify git, registry version, or path",
1660 );
1661 }
1662 let git_ref_count = usize::from(table.rev.is_some())
1663 + usize::from(table.tag.is_some())
1664 + usize::from(table.branch.is_some());
1665 if table.git.is_some() && git_ref_count > 1 {
1666 push_error(errors, &field, "dependency cannot specify more than one of tag, rev, or branch");
1667 }
1668 if table.git.is_some() && git_ref_count == 0 {
1669 push_error(errors, &field, "git dependency must specify tag, rev, or branch");
1670 }
1671 if table.branch.is_some() {
1672 push_warning(
1673 warnings,
1674 &field,
1675 "branch dependencies are non-reproducible for publishing; prefer tag, rev, or registry version",
1676 );
1677 }
1678 if let Some(version) = table.version.as_deref() {
1679 if let Err(error) = parse_registry_version_req(version) {
1680 push_error(errors, &field, error.to_string());
1681 }
1682 }
1683 if let Some(git) = table.git.as_deref() {
1684 if normalize_git_url(git).is_err() {
1685 push_error(errors, &field, format!("invalid git source '{git}'"));
1686 }
1687 }
1688 }
1689 }
1690 }
1691}
1692
1693pub(crate) fn validate_exports_for_publish(
1694 ctx: &ManifestContext,
1695 errors: &mut Vec<PackageCheckDiagnostic>,
1696 warnings: &mut Vec<PackageCheckDiagnostic>,
1697) -> Vec<PackageExportReport> {
1698 if ctx.manifest.exports.is_empty() {
1699 push_error(
1700 errors,
1701 "[exports]",
1702 "publishable packages require at least one stable export",
1703 );
1704 return Vec::new();
1705 }
1706
1707 let mut exports = Vec::new();
1708 for (name, rel_path) in &ctx.manifest.exports {
1709 let field = format!("[exports].{name}");
1710 if let Err(message) = validate_package_alias(name) {
1711 push_error(errors, &field, message);
1712 }
1713 let Ok(path) = safe_package_relative_path(&ctx.dir, rel_path) else {
1714 push_error(
1715 errors,
1716 &field,
1717 "export path must stay inside the package directory",
1718 );
1719 continue;
1720 };
1721 if path.extension() != Some(OsStr::new("harn")) {
1722 push_error(errors, &field, "export path must point at a .harn file");
1723 continue;
1724 }
1725 let content = match fs::read_to_string(&path) {
1726 Ok(content) => content,
1727 Err(error) => {
1728 push_error(
1729 errors,
1730 &field,
1731 format!("failed to read export {}: {error}", path.display()),
1732 );
1733 continue;
1734 }
1735 };
1736 if let Err(error) = parse_harn_source(&content) {
1737 push_error(errors, &field, format!("failed to parse export: {error}"));
1738 }
1739 let symbols = extract_api_symbols(&content);
1740 if symbols.is_empty() {
1741 push_warning(
1742 warnings,
1743 &field,
1744 "exported module has no public symbols to document",
1745 );
1746 }
1747 for symbol in &symbols {
1748 if symbol.docs.is_none() {
1749 push_warning(
1750 warnings,
1751 &field,
1752 format!(
1753 "public {} '{}' has no doc comment",
1754 symbol.kind, symbol.name
1755 ),
1756 );
1757 }
1758 }
1759 exports.push(PackageExportReport {
1760 name: name.clone(),
1761 path: rel_path.clone(),
1762 symbols,
1763 });
1764 }
1765 exports.sort_by(|left, right| left.name.cmp(&right.name));
1766 exports
1767}
1768
1769pub(crate) fn validate_package_interface_exports(
1770 ctx: &ManifestContext,
1771 errors: &mut Vec<PackageCheckDiagnostic>,
1772 warnings: &mut Vec<PackageCheckDiagnostic>,
1773) -> (Vec<PackageToolExportReport>, Vec<PackageSkillExportReport>) {
1774 let Some(package) = ctx.manifest.package.as_ref() else {
1775 return (Vec::new(), Vec::new());
1776 };
1777
1778 validate_permission_tokens(
1779 &package.permissions,
1780 "[package].permissions",
1781 errors,
1782 warnings,
1783 );
1784 validate_host_requirements(
1785 &package.host_requirements,
1786 "[package].host_requirements",
1787 errors,
1788 );
1789
1790 let mut tools = Vec::new();
1791 for (index, tool) in package.tools.iter().enumerate() {
1792 let field = format!("[[package.tools]] #{}", index + 1);
1793 if let Err(message) = validate_package_alias(&tool.name) {
1794 push_error(errors, format!("{field}.name"), message.to_string());
1795 }
1796 validate_required_manifest_string(&tool.module, &format!("{field}.module"), errors);
1797 validate_required_manifest_string(&tool.symbol, &format!("{field}.symbol"), errors);
1798 validate_package_module_path(ctx, &tool.module, &format!("{field}.module"), errors);
1799 validate_permission_tokens(
1800 &tool.permissions,
1801 &format!("{field}.permissions"),
1802 errors,
1803 warnings,
1804 );
1805 validate_host_requirements(
1806 &tool.host_requirements,
1807 &format!("{field}.host_requirements"),
1808 errors,
1809 );
1810 validate_schema_value(
1811 tool.input_schema.as_ref(),
1812 &format!("{field}.input_schema"),
1813 errors,
1814 );
1815 validate_schema_value(
1816 tool.output_schema.as_ref(),
1817 &format!("{field}.output_schema"),
1818 errors,
1819 );
1820 validate_tool_annotations(&tool.annotations, &format!("{field}.annotations"), errors);
1821 if tool.annotations.is_empty() {
1822 push_warning(
1823 warnings,
1824 format!("{field}.annotations"),
1825 "tool export has no annotations; policy evaluation will treat it conservatively",
1826 );
1827 }
1828 tools.push(PackageToolExportReport {
1829 name: tool.name.clone(),
1830 module: tool.module.clone(),
1831 symbol: tool.symbol.clone(),
1832 permissions: merge_package_requirements(&package.permissions, &tool.permissions),
1833 host_requirements: merge_package_requirements(
1834 &package.host_requirements,
1835 &tool.host_requirements,
1836 ),
1837 });
1838 }
1839 tools.sort_by(|left, right| left.name.cmp(&right.name));
1840
1841 let mut skills = Vec::new();
1842 for (index, skill) in package.skills.iter().enumerate() {
1843 let field = format!("[[package.skills]] #{}", index + 1);
1844 if let Err(message) = validate_package_alias(&skill.name) {
1845 push_error(errors, format!("{field}.name"), message.to_string());
1846 }
1847 validate_required_manifest_string(&skill.path, &format!("{field}.path"), errors);
1848 validate_package_skill_path(ctx, &skill.path, &format!("{field}.path"), errors);
1849 validate_permission_tokens(
1850 &skill.permissions,
1851 &format!("{field}.permissions"),
1852 errors,
1853 warnings,
1854 );
1855 validate_host_requirements(
1856 &skill.host_requirements,
1857 &format!("{field}.host_requirements"),
1858 errors,
1859 );
1860 skills.push(PackageSkillExportReport {
1861 name: skill.name.clone(),
1862 path: skill.path.clone(),
1863 permissions: merge_package_requirements(&package.permissions, &skill.permissions),
1864 host_requirements: merge_package_requirements(
1865 &package.host_requirements,
1866 &skill.host_requirements,
1867 ),
1868 });
1869 }
1870 skills.sort_by(|left, right| left.name.cmp(&right.name));
1871
1872 (tools, skills)
1873}
1874
1875pub(crate) fn merge_package_requirements(base: &[String], item: &[String]) -> Vec<String> {
1876 let mut merged = BTreeSet::new();
1877 merged.extend(
1878 base.iter()
1879 .filter_map(|value| normalized_requirement(value)),
1880 );
1881 merged.extend(
1882 item.iter()
1883 .filter_map(|value| normalized_requirement(value)),
1884 );
1885 merged.into_iter().collect()
1886}
1887
1888fn normalized_requirement(value: &str) -> Option<String> {
1889 let trimmed = value.trim();
1890 (!trimmed.is_empty()).then(|| trimmed.to_string())
1891}
1892
1893fn validate_required_manifest_string(
1894 value: &str,
1895 field: &str,
1896 errors: &mut Vec<PackageCheckDiagnostic>,
1897) {
1898 if value.trim().is_empty() {
1899 push_error(errors, field, format!("missing required {field}"));
1900 }
1901}
1902
1903fn validate_permission_tokens(
1904 permissions: &[String],
1905 field: &str,
1906 errors: &mut Vec<PackageCheckDiagnostic>,
1907 warnings: &mut Vec<PackageCheckDiagnostic>,
1908) {
1909 let mut seen = BTreeSet::new();
1910 for permission in permissions {
1911 let trimmed = permission.trim();
1912 if trimmed.is_empty() {
1913 push_error(errors, field, "permission entries cannot be empty");
1914 continue;
1915 }
1916 if trimmed.chars().any(char::is_whitespace) {
1917 push_error(
1918 errors,
1919 field,
1920 format!("permission {permission:?} cannot contain whitespace"),
1921 );
1922 }
1923 if !trimmed.contains(':') && !trimmed.contains('.') {
1924 push_warning(
1925 warnings,
1926 field,
1927 format!("permission {permission:?} should use a namespaced token"),
1928 );
1929 }
1930 if !seen.insert(trimmed.to_string()) {
1931 push_warning(
1932 warnings,
1933 field,
1934 format!("duplicate permission {permission:?}"),
1935 );
1936 }
1937 }
1938}
1939
1940pub(crate) fn validate_host_requirements(
1941 requirements: &[String],
1942 field: &str,
1943 errors: &mut Vec<PackageCheckDiagnostic>,
1944) {
1945 let mut seen = BTreeSet::new();
1946 for requirement in requirements {
1947 let trimmed = requirement.trim();
1948 if trimmed.is_empty() {
1949 push_error(errors, field, "host requirement entries cannot be empty");
1950 continue;
1951 }
1952 let Some((capability, operation)) = trimmed.split_once('.') else {
1953 push_error(
1954 errors,
1955 field,
1956 format!("host requirement {requirement:?} must use capability.operation"),
1957 );
1958 continue;
1959 };
1960 if !valid_identifier(capability)
1961 || !(valid_identifier(operation) || operation == "*")
1962 || trimmed.matches('.').count() != 1
1963 {
1964 push_error(
1965 errors,
1966 field,
1967 format!("host requirement {requirement:?} must use valid capability.operation identifiers"),
1968 );
1969 }
1970 if !seen.insert(trimmed.to_string()) {
1971 push_error(
1972 errors,
1973 field,
1974 format!("duplicate host requirement {requirement:?}"),
1975 );
1976 }
1977 }
1978}
1979
1980fn validate_package_module_path(
1981 ctx: &ManifestContext,
1982 rel_path: &str,
1983 field: &str,
1984 errors: &mut Vec<PackageCheckDiagnostic>,
1985) {
1986 let Ok(path) = safe_package_relative_path(&ctx.dir, rel_path) else {
1987 push_error(errors, field, "module path must stay inside the package");
1988 return;
1989 };
1990 if path.extension() != Some(OsStr::new("harn")) {
1991 push_error(errors, field, "module path must point at a .harn file");
1992 return;
1993 }
1994 match fs::read_to_string(&path) {
1995 Ok(content) => {
1996 if let Err(error) = parse_harn_source(&content) {
1997 push_error(errors, field, format!("failed to parse module: {error}"));
1998 }
1999 }
2000 Err(error) => push_error(
2001 errors,
2002 field,
2003 format!("failed to read module {}: {error}", path.display()),
2004 ),
2005 }
2006}
2007
2008fn validate_package_skill_path(
2009 ctx: &ManifestContext,
2010 rel_path: &str,
2011 field: &str,
2012 errors: &mut Vec<PackageCheckDiagnostic>,
2013) {
2014 let Ok(path) = safe_package_relative_path(&ctx.dir, rel_path) else {
2015 push_error(errors, field, "skill path must stay inside the package");
2016 return;
2017 };
2018 let skill_file = if path.is_dir() {
2019 path.join("SKILL.md")
2020 } else {
2021 path.clone()
2022 };
2023 if skill_file.file_name() != Some(OsStr::new("SKILL.md")) {
2024 push_error(
2025 errors,
2026 field,
2027 "skill path must be a SKILL.md file or skill directory",
2028 );
2029 return;
2030 }
2031 match fs::read_to_string(&skill_file) {
2032 Ok(content) => {
2033 let (frontmatter, _) = harn_vm::skills::split_frontmatter(&content);
2034 if let Err(error) = harn_vm::skills::parse_frontmatter(frontmatter) {
2035 push_error(
2036 errors,
2037 field,
2038 format!("invalid SKILL.md frontmatter: {error}"),
2039 );
2040 }
2041 }
2042 Err(error) => push_error(
2043 errors,
2044 field,
2045 format!("failed to read skill {}: {error}", skill_file.display()),
2046 ),
2047 }
2048}
2049
2050fn validate_schema_value(
2051 value: Option<&toml::Value>,
2052 field: &str,
2053 errors: &mut Vec<PackageCheckDiagnostic>,
2054) {
2055 let Some(value) = value else {
2056 return;
2057 };
2058 let json = match toml_value_to_json(value) {
2059 Ok(json) => json,
2060 Err(error) => {
2061 push_error(errors, field, error);
2062 return;
2063 }
2064 };
2065 let Some(object) = json.as_object() else {
2066 push_error(errors, field, "schema must be a table/object");
2067 return;
2068 };
2069 if let Some(schema_type) = object.get("type") {
2070 if !schema_type.is_string() {
2071 push_error(errors, field, "schema `type` must be a string when present");
2072 }
2073 }
2074 if let Some(required) = object.get("required") {
2075 let valid = required
2076 .as_array()
2077 .is_some_and(|items| items.iter().all(|item| item.as_str().is_some()));
2078 if !valid {
2079 push_error(errors, field, "schema `required` must be a list of strings");
2080 }
2081 }
2082}
2083
2084fn validate_tool_annotations(
2085 annotations: &BTreeMap<String, toml::Value>,
2086 field: &str,
2087 errors: &mut Vec<PackageCheckDiagnostic>,
2088) {
2089 if annotations.is_empty() {
2090 return;
2091 }
2092 let json = match toml_value_to_json(&toml::Value::Table(
2093 annotations
2094 .clone()
2095 .into_iter()
2096 .collect::<toml::map::Map<String, toml::Value>>(),
2097 )) {
2098 Ok(json) => json,
2099 Err(error) => {
2100 push_error(errors, field, error);
2101 return;
2102 }
2103 };
2104 if let Err(error) = serde_json::from_value::<harn_vm::tool_annotations::ToolAnnotations>(json) {
2105 push_error(
2106 errors,
2107 field,
2108 format!("annotations do not match ToolAnnotations: {error}"),
2109 );
2110 }
2111}
2112
2113fn toml_value_to_json(value: &toml::Value) -> Result<serde_json::Value, String> {
2114 serde_json::to_value(value).map_err(|error| format!("failed to normalize TOML value: {error}"))
2115}
2116
2117pub(crate) fn parse_harn_source(source: &str) -> Result<(), PackageError> {
2118 let mut lexer = harn_lexer::Lexer::new(source);
2119 let tokens = lexer.tokenize().map_err(|error| error.to_string())?;
2120 let mut parser = harn_parser::Parser::new(tokens);
2121 parser
2122 .parse()
2123 .map(|_| ())
2124 .map_err(|error| PackageError::Ops(error.to_string()))
2125}
2126
2127pub(crate) fn safe_package_relative_path(
2128 root: &Path,
2129 rel_path: &str,
2130) -> Result<PathBuf, PackageError> {
2131 let rel = PathBuf::from(rel_path);
2132 if rel.is_absolute()
2133 || has_windows_rooted_or_drive_relative_prefix(rel_path)
2134 || has_windows_separator_escape(rel_path)
2135 || rel.components().any(|component| {
2136 matches!(
2137 component,
2138 std::path::Component::ParentDir
2139 | std::path::Component::Prefix(_)
2140 | std::path::Component::RootDir
2141 )
2142 })
2143 {
2144 return Err(format!("path {rel_path:?} escapes package root").into());
2145 }
2146 Ok(root.join(rel))
2147}
2148
2149fn has_windows_rooted_or_drive_relative_prefix(path: &str) -> bool {
2150 let normalized = path.replace('\\', "/");
2151 let bytes = normalized.as_bytes();
2152 normalized.starts_with('/')
2153 || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
2154}
2155
2156fn has_windows_separator_escape(path: &str) -> bool {
2157 let normalized = path.replace('\\', "/");
2158 Path::new(&normalized).components().any(|component| {
2159 matches!(
2160 component,
2161 std::path::Component::ParentDir
2162 | std::path::Component::Prefix(_)
2163 | std::path::Component::RootDir
2164 )
2165 })
2166}
2167
2168pub(crate) fn extract_api_symbols(source: &str) -> Vec<PackageApiSymbol> {
2169 static DECL_RE: OnceLock<Regex> = OnceLock::new();
2170 let decl_re = DECL_RE.get_or_init(|| {
2171 Regex::new(r"^\s*pub\s+(fn|pipeline|tool|skill|struct|enum|type|interface)\s+([A-Za-z_][A-Za-z0-9_]*)\b(.*)$")
2172 .expect("valid declaration regex")
2173 });
2174 let mut docs: Vec<String> = Vec::new();
2175 let mut symbols = Vec::new();
2176 let mut in_block_doc = false;
2177 for line in source.lines() {
2178 let trimmed = line.trim();
2179 if in_block_doc {
2180 let (content, closes) = match trimmed.split_once("*/") {
2184 Some((before, _)) => (before, true),
2185 None => (trimmed, false),
2186 };
2187 let stripped = content
2188 .strip_prefix("* ")
2189 .or_else(|| content.strip_prefix('*'))
2190 .unwrap_or(content)
2191 .trim();
2192 if !stripped.is_empty() {
2193 docs.push(stripped.to_string());
2194 }
2195 if closes {
2196 in_block_doc = false;
2197 }
2198 continue;
2199 }
2200 if let Some(doc) = trimmed.strip_prefix("///") {
2201 docs.push(doc.trim().to_string());
2202 continue;
2203 }
2204 if let Some(rest) = trimmed.strip_prefix("/**") {
2205 if let Some((inner, _)) = rest.split_once("*/") {
2209 let stripped = inner.trim();
2210 if !stripped.is_empty() {
2211 docs.push(stripped.to_string());
2212 }
2213 } else {
2214 let stripped = rest.trim();
2215 if !stripped.is_empty() {
2216 docs.push(stripped.to_string());
2217 }
2218 in_block_doc = true;
2219 }
2220 continue;
2221 }
2222 if trimmed.is_empty() {
2223 continue;
2224 }
2225 if let Some(captures) = decl_re.captures(line) {
2226 let kind = captures.get(1).expect("kind").as_str().to_string();
2227 let name = captures.get(2).expect("name").as_str().to_string();
2228 let signature = trim_signature(line);
2229 let doc_text = (!docs.is_empty()).then(|| docs.join("\n"));
2230 symbols.push(PackageApiSymbol {
2231 kind,
2232 name,
2233 signature,
2234 docs: doc_text,
2235 });
2236 }
2237 docs.clear();
2238 }
2239 symbols
2240}
2241
2242pub(crate) fn trim_signature(line: &str) -> String {
2243 let mut signature = line.trim().to_string();
2244 if let Some((before, _)) = signature.split_once('{') {
2245 signature = before.trim_end().to_string();
2246 }
2247 signature
2248}
2249
2250pub(crate) fn supports_current_harn(range: &str) -> bool {
2251 let current = env!("CARGO_PKG_VERSION");
2252 let Some((major, minor)) = parse_major_minor(current) else {
2253 return true;
2254 };
2255 let range = range.trim();
2256 if range.is_empty() {
2257 return false;
2258 }
2259 if let Some(rest) = range.strip_prefix('^') {
2260 return parse_major_minor(rest).is_some_and(|(m, n)| m == major && n == minor);
2261 }
2262 if !range.contains([',', '<', '>', '=']) {
2263 return parse_major_minor(range).is_some_and(|(m, n)| m == major && n == minor);
2264 }
2265
2266 let current_value = major * 1000 + minor;
2267 let mut lower_ok = true;
2268 let mut upper_ok = true;
2269 let mut saw_constraint = false;
2270 for raw in range.split(',') {
2271 let part = raw.trim();
2272 if part.is_empty() {
2273 continue;
2274 }
2275 saw_constraint = true;
2276 if let Some(rest) = part.strip_prefix(">=") {
2277 if let Some((m, n)) = parse_major_minor(rest.trim()) {
2278 lower_ok &= current_value >= m * 1000 + n;
2279 } else {
2280 return false;
2281 }
2282 } else if let Some(rest) = part.strip_prefix('>') {
2283 if let Some((m, n)) = parse_major_minor(rest.trim()) {
2284 lower_ok &= current_value > m * 1000 + n;
2285 } else {
2286 return false;
2287 }
2288 } else if let Some(rest) = part.strip_prefix("<=") {
2289 if let Some((m, n)) = parse_major_minor(rest.trim()) {
2290 upper_ok &= current_value <= m * 1000 + n;
2291 } else {
2292 return false;
2293 }
2294 } else if let Some(rest) = part.strip_prefix('<') {
2295 if let Some((m, n)) = parse_major_minor(rest.trim()) {
2296 upper_ok &= current_value < m * 1000 + n;
2297 } else {
2298 return false;
2299 }
2300 } else if let Some(rest) = part.strip_prefix('=') {
2301 if let Some((m, n)) = parse_major_minor(rest.trim()) {
2302 lower_ok &= current_value == m * 1000 + n;
2303 upper_ok &= current_value == m * 1000 + n;
2304 } else {
2305 return false;
2306 }
2307 } else {
2308 return false;
2309 }
2310 }
2311 saw_constraint && lower_ok && upper_ok
2312}
2313
2314pub(crate) fn current_harn_range_example() -> String {
2315 let current = env!("CARGO_PKG_VERSION");
2316 let Some((major, minor)) = parse_major_minor(current) else {
2317 return ">=0.7,<0.8".to_string();
2318 };
2319 format!(">={major}.{minor},<{major}.{}", minor + 1)
2320}
2321
2322pub(crate) fn current_harn_line_label() -> String {
2323 let current = env!("CARGO_PKG_VERSION");
2324 let Some((major, minor)) = parse_major_minor(current) else {
2325 return "0.7".to_string();
2326 };
2327 format!("{major}.{minor}")
2328}
2329
2330pub(crate) fn parse_major_minor(raw: &str) -> Option<(u64, u64)> {
2331 let raw = raw.trim().trim_start_matches('v');
2332 let mut parts = raw.split('.');
2333 let major = parts.next()?.parse().ok()?;
2334 let minor = parts.next()?.trim_end_matches('x').parse().ok()?;
2335 Some((major, minor))
2336}
2337
2338pub(crate) fn collect_package_files(root: &Path) -> Result<Vec<String>, PackageError> {
2339 let mut files = Vec::new();
2340 collect_package_files_inner(root, root, &mut files)?;
2341 files.sort();
2342 Ok(files)
2343}
2344
2345pub(crate) fn collect_package_files_inner(
2346 root: &Path,
2347 dir: &Path,
2348 out: &mut Vec<String>,
2349) -> Result<(), PackageError> {
2350 for entry in
2351 fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?
2352 {
2353 let entry =
2354 entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?;
2355 let path = entry.path();
2356 let file_type = entry
2357 .file_type()
2358 .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?;
2359 if file_type.is_symlink() {
2360 continue;
2361 }
2362 if file_type.is_dir() {
2363 let rel = path
2364 .strip_prefix(root)
2365 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
2366 if should_skip_package_dir(rel) {
2367 continue;
2368 }
2369 collect_package_files_inner(root, &path, out)?;
2370 } else if file_type.is_file() {
2371 let rel = path
2372 .strip_prefix(root)
2373 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?
2374 .to_string_lossy()
2375 .replace('\\', "/");
2376 out.push(rel);
2377 }
2378 }
2379 Ok(())
2380}
2381
2382pub(crate) fn should_skip_package_dir(rel: &Path) -> bool {
2383 if rel == Path::new("docs").join("dist") {
2384 return true;
2385 }
2386 rel.components().any(|component| {
2387 matches!(
2388 component.as_os_str().to_str(),
2389 Some(".git" | ".harn" | "target" | "node_modules")
2390 )
2391 })
2392}
2393
2394pub(crate) fn default_artifact_dir(ctx: &ManifestContext, report: &PackageCheckReport) -> PathBuf {
2395 let name = report.name.as_deref().unwrap_or("package");
2396 let version = report.version.as_deref().unwrap_or("0.0.0");
2397 ctx.dir
2398 .join(".harn")
2399 .join("dist")
2400 .join(format!("{name}-{version}"))
2401}
2402
2403pub(crate) fn fail_if_package_errors(report: &PackageCheckReport) -> Result<(), PackageError> {
2404 if report.errors.is_empty() {
2405 return Ok(());
2406 }
2407 Err(format!(
2408 "package check failed:\n{}",
2409 report
2410 .errors
2411 .iter()
2412 .map(|diagnostic| format!("- {}: {}", diagnostic.field, diagnostic.message))
2413 .collect::<Vec<_>>()
2414 .join("\n")
2415 )
2416 .into())
2417}
2418
2419pub(crate) fn render_package_api_docs(report: &PackageCheckReport) -> String {
2420 let title = report.name.as_deref().unwrap_or("package");
2421 let mut out = format!("# API Reference: {title}\n\nGenerated by `harn package docs`.\n");
2422 if let Some(version) = report.version.as_deref() {
2423 out.push_str(&format!("\nVersion: `{version}`\n"));
2424 }
2425 for export in &report.exports {
2426 out.push_str(&format!(
2427 "\n## Export `{}`\n\n`{}`\n",
2428 export.name, export.path
2429 ));
2430 for symbol in &export.symbols {
2431 out.push_str(&format!("\n### {} `{}`\n\n", symbol.kind, symbol.name));
2432 if let Some(docs) = symbol.docs.as_deref() {
2433 out.push_str(docs);
2434 out.push_str("\n\n");
2435 }
2436 out.push_str("```harn\n");
2437 out.push_str(&symbol.signature);
2438 out.push_str("\n```\n");
2439 }
2440 }
2441 if !report.tools.is_empty() {
2442 out.push_str("\n## Tool Exports\n");
2443 for tool in &report.tools {
2444 out.push_str(&format!(
2445 "\n### `{}`\n\n- module: `{}`\n- symbol: `{}`\n",
2446 tool.name, tool.module, tool.symbol
2447 ));
2448 if !tool.permissions.is_empty() {
2449 out.push_str(&format!(
2450 "- permissions: `{}`\n",
2451 tool.permissions.join("`, `")
2452 ));
2453 }
2454 if !tool.host_requirements.is_empty() {
2455 out.push_str(&format!(
2456 "- host requirements: `{}`\n",
2457 tool.host_requirements.join("`, `")
2458 ));
2459 }
2460 }
2461 }
2462 if !report.skills.is_empty() {
2463 out.push_str("\n## Skill Exports\n");
2464 for skill in &report.skills {
2465 out.push_str(&format!("\n### `{}`\n\n`{}`\n", skill.name, skill.path));
2466 }
2467 }
2468 out
2469}
2470
2471pub(crate) fn normalize_newlines(input: &str) -> String {
2472 input.replace("\r\n", "\n")
2473}
2474
2475pub(crate) fn print_package_check_report(report: &PackageCheckReport) {
2476 println!(
2477 "Package {} {}",
2478 report.name.as_deref().unwrap_or("<unnamed>"),
2479 report.version.as_deref().unwrap_or("<unversioned>")
2480 );
2481 println!("manifest: {}", report.manifest_path);
2482 for export in &report.exports {
2483 println!(
2484 "export {} -> {} ({} public symbol(s))",
2485 export.name,
2486 export.path,
2487 export.symbols.len()
2488 );
2489 }
2490 for tool in &report.tools {
2491 println!("tool {} -> {}::{}", tool.name, tool.module, tool.symbol);
2492 }
2493 for skill in &report.skills {
2494 println!("skill {} -> {}", skill.name, skill.path);
2495 }
2496 if !report.warnings.is_empty() {
2497 println!("\nwarnings:");
2498 for warning in &report.warnings {
2499 println!("- {}: {}", warning.field, warning.message);
2500 }
2501 }
2502 if !report.errors.is_empty() {
2503 println!("\nerrors:");
2504 for error in &report.errors {
2505 println!("- {}: {}", error.field, error.message);
2506 }
2507 } else {
2508 println!("\npackage check passed");
2509 }
2510}
2511
2512pub(crate) fn print_package_pack_report(report: &PackagePackReport) {
2513 if report.dry_run {
2514 println!("Package pack dry run succeeded.");
2515 } else {
2516 println!("Packed package artifact.");
2517 }
2518 println!("artifact: {}", report.artifact_dir);
2519 println!("files:");
2520 for file in &report.files {
2521 println!("- {file}");
2522 }
2523}
2524
2525pub(crate) fn print_package_list_report(report: &PackageListReport) {
2526 println!("manifest: {}", report.manifest_path);
2527 println!("lock: {}", report.lock_path);
2528 if !report.lock_present {
2529 println!("lock status: missing");
2530 if report.dependency_count > 0 {
2531 println!(
2532 "run `harn install` to resolve {} dependency(s)",
2533 report.dependency_count
2534 );
2535 }
2536 return;
2537 }
2538 if report.packages.is_empty() {
2539 println!("No packages installed.");
2540 return;
2541 }
2542 println!("Packages ({}):", report.packages.len());
2543 for entry in &report.packages {
2544 let version = entry.package_version.as_deref().unwrap_or("unversioned");
2545 let status = if entry.materialized {
2546 "installed"
2547 } else {
2548 "missing"
2549 };
2550 println!(
2551 " {} {} {} integrity={}",
2552 entry.name, version, status, entry.integrity
2553 );
2554 if !entry.exports.modules.is_empty() {
2555 let modules: Vec<&str> = entry
2556 .exports
2557 .modules
2558 .iter()
2559 .map(|export| export.name.as_str())
2560 .collect();
2561 println!(" modules: {}", modules.join(", "));
2562 }
2563 if !entry.exports.tools.is_empty() {
2564 let tools: Vec<&str> = entry
2565 .exports
2566 .tools
2567 .iter()
2568 .map(|export| export.name.as_str())
2569 .collect();
2570 println!(" tools: {}", tools.join(", "));
2571 }
2572 if !entry.exports.skills.is_empty() {
2573 let skills: Vec<&str> = entry
2574 .exports
2575 .skills
2576 .iter()
2577 .map(|export| export.name.as_str())
2578 .collect();
2579 println!(" skills: {}", skills.join(", "));
2580 }
2581 if !entry.permissions.is_empty() {
2582 println!(" permissions: {}", entry.permissions.join(", "));
2583 }
2584 if !entry.host_requirements.is_empty() {
2585 println!(
2586 " host requirements: {}",
2587 entry.host_requirements.join(", ")
2588 );
2589 }
2590 }
2591}
2592
2593pub(crate) fn print_package_doctor_report(report: &PackageDoctorReport) {
2594 println!("Package doctor");
2595 println!("manifest: {}", report.manifest_path);
2596 println!("lock: {}", report.lock_path);
2597 if report.diagnostics.is_empty() {
2598 println!("ok: no package issues found");
2599 return;
2600 }
2601 for diagnostic in &report.diagnostics {
2602 println!(
2603 "{} [{}] {}",
2604 diagnostic.severity, diagnostic.code, diagnostic.message
2605 );
2606 if let Some(help) = diagnostic.help.as_deref() {
2607 println!(" help: {help}");
2608 }
2609 }
2610}
2611
2612#[cfg(test)]
2613mod tests {
2614 use super::*;
2615 use crate::package::test_support::*;
2616
2617 #[test]
2618 fn package_check_accepts_publishable_package() {
2619 let tmp = tempfile::tempdir().unwrap();
2620 write_publishable_package(tmp.path());
2621
2622 let report = check_package_impl(Some(tmp.path())).unwrap();
2623
2624 assert!(report.errors.is_empty(), "{:?}", report.errors);
2625 assert_eq!(report.name.as_deref(), Some("acme-lib"));
2626 assert_eq!(report.exports[0].symbols[0].name, "greet");
2627 }
2628
2629 #[test]
2630 fn package_check_rejects_path_dependencies_and_bad_harn_range() {
2631 let tmp = tempfile::tempdir().unwrap();
2632 write_publishable_package(tmp.path());
2633 fs::write(
2634 tmp.path().join(MANIFEST),
2635 r#"[package]
2636 name = "acme-lib"
2637 version = "0.1.0"
2638 description = "Acme helpers"
2639 license = "MIT"
2640 repository = "https://github.com/acme/acme-lib"
2641 harn = ">=999.0,<999.1"
2642 docs_url = "docs/api.md"
2643
2644 [exports]
2645 lib = "lib/main.harn"
2646
2647 [dependencies]
2648 local = { path = "../local" }
2649 "#,
2650 )
2651 .unwrap();
2652
2653 let report = check_package_impl(Some(tmp.path())).unwrap();
2654 let messages = report
2655 .errors
2656 .iter()
2657 .map(|diagnostic| diagnostic.message.as_str())
2658 .collect::<Vec<_>>()
2659 .join("\n");
2660
2661 assert!(messages.contains("unsupported Harn version range"));
2662 assert!(messages.contains("path dependencies are not publishable"));
2663 }
2664
2665 #[test]
2666 fn package_check_warns_on_branch_dependency() {
2667 let tmp = tempfile::tempdir().unwrap();
2668 write_publishable_package(tmp.path());
2669 fs::write(
2670 tmp.path().join(MANIFEST),
2671 format!(
2672 r#"[package]
2673name = "acme-lib"
2674version = "0.1.0"
2675description = "Acme helpers"
2676license = "MIT"
2677repository = "https://github.com/acme/acme-lib"
2678harn = "{}"
2679docs_url = "docs/api.md"
2680
2681[exports]
2682lib = "lib/main.harn"
2683
2684[dependencies]
2685remote = {{ git = "https://github.com/acme/remote-lib", branch = "main" }}
2686"#,
2687 current_harn_range_example()
2688 ),
2689 )
2690 .unwrap();
2691
2692 let report = check_package_impl(Some(tmp.path())).unwrap();
2693 let warnings = report
2694 .warnings
2695 .iter()
2696 .map(|diagnostic| diagnostic.message.as_str())
2697 .collect::<Vec<_>>()
2698 .join("\n");
2699
2700 assert!(report.errors.is_empty(), "{:?}", report.errors);
2701 assert!(warnings.contains("branch dependencies are non-reproducible"));
2702 }
2703
2704 #[test]
2705 fn extract_api_symbols_recognizes_block_doc_comments() {
2706 let single = extract_api_symbols("/** Block doc. */\npub fn one() {}\n");
2711 assert_eq!(single.len(), 1);
2712 assert_eq!(single[0].docs.as_deref(), Some("Block doc."));
2713
2714 let multi =
2715 extract_api_symbols("/**\n * First line.\n * Second line.\n */\npub fn two() {}\n");
2716 assert_eq!(multi.len(), 1);
2717 assert_eq!(multi[0].docs.as_deref(), Some("First line.\nSecond line."));
2718
2719 let triple = extract_api_symbols("/// Slash doc.\npub fn three() {}\n");
2720 assert_eq!(triple.len(), 1);
2721 assert_eq!(triple[0].docs.as_deref(), Some("Slash doc."));
2722
2723 let detached = extract_api_symbols("/** Detached. */\nlet x = 1\npub fn four() {}\n");
2727 assert_eq!(detached.len(), 1);
2728 assert!(detached[0].docs.is_none());
2729 }
2730
2731 #[test]
2732 fn package_docs_and_pack_use_exports() {
2733 let tmp = tempfile::tempdir().unwrap();
2734 write_publishable_package(tmp.path());
2735
2736 let docs_path = generate_package_docs_impl(Some(tmp.path()), None, false).unwrap();
2737 let docs = fs::read_to_string(docs_path).unwrap();
2738 assert!(docs.contains("### fn `greet`"));
2739 assert!(docs.contains("Return a greeting."));
2740
2741 let pack = pack_package_impl(Some(tmp.path()), None, true).unwrap();
2742 assert!(pack.files.contains(&"harn.toml".to_string()));
2743 assert!(pack.files.contains(&"lib/main.harn".to_string()));
2744 }
2745
2746 #[test]
2747 fn package_pack_skips_generated_docs_dist() {
2748 let tmp = tempfile::tempdir().unwrap();
2749 write_publishable_package(tmp.path());
2750 fs::create_dir_all(tmp.path().join("docs/dist")).unwrap();
2751 fs::write(tmp.path().join("docs/dist/index.html"), "<html></html>\n").unwrap();
2752
2753 let pack = pack_package_impl(Some(tmp.path()), None, true).unwrap();
2754
2755 assert!(
2756 !pack.files.iter().any(|path| path.starts_with("docs/dist/")),
2757 "{:?}",
2758 pack.files
2759 );
2760 }
2761
2762 #[test]
2763 fn publish_dry_run_builds_tag_command_and_index_diff() {
2764 let tmp = tempfile::tempdir().unwrap();
2765 write_publishable_package(tmp.path());
2766 write_release_changelog(tmp.path(), "0.1.0");
2767 let _remote = init_publishable_repo(tmp.path());
2768 let index = r#"version = 1
2769
2770[[package]]
2771name = "acme-lib"
2772repository = "https://github.com/acme/acme-lib"
2773
2774[[package.version]]
2775version = "0.0.1"
2776git = "https://github.com/acme/acme-lib"
2777rev = "deadbeef"
2778
2779[[package]]
2780name = "other-lib"
2781repository = "https://github.com/acme/other-lib"
2782
2783[[package.version]]
2784version = "1.0.0"
2785git = "https://github.com/acme/other-lib"
2786rev = "feedface"
2787"#;
2788 let index_path = Path::new("package-index/harn-package-index.toml");
2789 let options = PackagePublishOptions {
2790 dry_run: true,
2791 remote: "origin",
2792 index_repo: "burin-labs/harn-cloud",
2793 index_path,
2794 registry_name: None,
2795 skip_index_pr: false,
2796 registry: None,
2797 };
2798
2799 let plan =
2800 prepare_publish_plan(Some(tmp.path()), &options, index.to_string(), "fixture").unwrap();
2801
2802 assert!(plan.tag_command.contains("git -C"));
2803 assert!(plan.tag_command.contains("tag v0.1.0"));
2804 assert!(plan.index_diff.contains("+version = \"0.1.0\""));
2805 assert!(plan.index_diff.contains("+tag = \"v0.1.0\""));
2806 assert!(plan
2807 .index_diff
2808 .contains(&format!("+rev = \"{}\"", plan.sha)));
2809 assert!(plan
2810 .index_diff
2811 .contains(&format!("+sha = \"{}\"", plan.sha)));
2812 let acme_pos = plan
2813 .updated_index_content
2814 .find("name = \"acme-lib\"")
2815 .unwrap();
2816 let other_pos = plan
2817 .updated_index_content
2818 .find("name = \"other-lib\"")
2819 .unwrap();
2820 let new_version_pos = plan
2821 .updated_index_content
2822 .find("version = \"0.1.0\"")
2823 .unwrap();
2824 assert!(acme_pos < new_version_pos && new_version_pos < other_pos);
2825 }
2826
2827 #[test]
2828 fn publish_preflight_rejects_existing_tag_and_missing_changelog_entry() {
2829 let tmp = tempfile::tempdir().unwrap();
2830 write_publishable_package(tmp.path());
2831 let _remote = init_publishable_repo(tmp.path());
2832 let index_path = Path::new("package-index/harn-package-index.toml");
2833 let options = PackagePublishOptions {
2834 dry_run: true,
2835 remote: "origin",
2836 index_repo: "burin-labs/harn-cloud",
2837 index_path,
2838 registry_name: None,
2839 skip_index_pr: false,
2840 registry: None,
2841 };
2842
2843 let missing_changelog = prepare_publish_plan(
2844 Some(tmp.path()),
2845 &options,
2846 "version = 1\n".to_string(),
2847 "fixture",
2848 )
2849 .unwrap_err()
2850 .to_string();
2851 assert!(missing_changelog.contains("CHANGELOG.md"));
2852
2853 write_release_changelog(tmp.path(), "0.1.0");
2854 run_git(tmp.path(), &["add", "CHANGELOG.md"]);
2855 run_git(tmp.path(), &["commit", "-m", "add changelog"]);
2856 run_git(tmp.path(), &["tag", "v0.1.0"]);
2857
2858 let existing_tag = prepare_publish_plan(
2859 Some(tmp.path()),
2860 &options,
2861 "version = 1\n".to_string(),
2862 "fixture",
2863 )
2864 .unwrap_err()
2865 .to_string();
2866 assert!(existing_tag.contains("already exists locally"));
2867 }
2868
2869 #[test]
2870 fn publish_preflight_rejects_dirty_worktree() {
2871 let tmp = tempfile::tempdir().unwrap();
2872 write_publishable_package(tmp.path());
2873 write_release_changelog(tmp.path(), "0.1.0");
2874 let _remote = init_publishable_repo(tmp.path());
2875 fs::write(tmp.path().join("scratch.txt"), "dirty\n").unwrap();
2876 let index_path = Path::new("package-index/harn-package-index.toml");
2877 let options = PackagePublishOptions {
2878 dry_run: true,
2879 remote: "origin",
2880 index_repo: "burin-labs/harn-cloud",
2881 index_path,
2882 registry_name: None,
2883 skip_index_pr: false,
2884 registry: None,
2885 };
2886
2887 let error = prepare_publish_plan(
2888 Some(tmp.path()),
2889 &options,
2890 "version = 1\n".to_string(),
2891 "fixture",
2892 )
2893 .unwrap_err()
2894 .to_string();
2895
2896 assert!(error.contains("working tree must be clean"));
2897 assert!(error.contains("scratch.txt"));
2898 }
2899
2900 #[cfg(unix)]
2901 #[test]
2902 fn package_pack_does_not_follow_symlinked_files() {
2903 let tmp = tempfile::tempdir().unwrap();
2904 write_publishable_package(tmp.path());
2905 let outside = tempfile::NamedTempFile::new().unwrap();
2906 fs::write(outside.path(), "secret\n").unwrap();
2907 std::os::unix::fs::symlink(outside.path(), tmp.path().join("secret.txt")).unwrap();
2908
2909 let pack = pack_package_impl(Some(tmp.path()), None, true).unwrap();
2910
2911 assert!(
2912 !pack.files.contains(&"secret.txt".to_string()),
2913 "{:?}",
2914 pack.files
2915 );
2916 }
2917
2918 #[test]
2919 fn package_relative_paths_reject_windows_rooted_forms() {
2920 let tmp = tempfile::tempdir().unwrap();
2921 for rel_path in [
2922 "/repo/secret.harn",
2923 r"\repo\secret.harn",
2924 r"C:\repo\secret.harn",
2925 "C:secret.harn",
2926 r"\\server\share\secret.harn",
2927 r"..\secret.harn",
2928 r"lib\..\secret.harn",
2929 r"lib/..\secret.harn",
2930 ] {
2931 assert!(
2932 safe_package_relative_path(tmp.path(), rel_path).is_err(),
2933 "{rel_path:?} must not be accepted as package-relative"
2934 );
2935 }
2936 }
2937
2938 #[test]
2939 fn package_check_validates_tool_and_skill_exports() {
2940 let tmp = tempfile::tempdir().unwrap();
2941 write_publishable_package(tmp.path());
2942 fs::create_dir_all(tmp.path().join("skills/review")).unwrap();
2943 fs::write(
2944 tmp.path().join("harn.toml"),
2945 format!(
2946 r#"[package]
2947name = "acme-lib"
2948version = "0.1.0"
2949description = "Acme helpers"
2950license = "MIT"
2951repository = "https://github.com/acme/acme-lib"
2952harn = "{}"
2953docs_url = "docs/api.md"
2954permissions = ["tool:read_only"]
2955host_requirements = ["workspace.read_text"]
2956
2957[exports]
2958lib = "lib/main.harn"
2959
2960[[package.tools]]
2961name = "read-note"
2962module = "lib/main.harn"
2963symbol = "tools"
2964permissions = ["tool:read_only"]
2965
2966[package.tools.input_schema]
2967type = "object"
2968required = ["path"]
2969
2970[package.tools.annotations]
2971kind = "read"
2972side_effect_level = "read_only"
2973
2974[package.tools.annotations.arg_schema]
2975required = ["path"]
2976
2977[[package.skills]]
2978name = "review"
2979path = "skills/review"
2980permissions = ["skill:prompt"]
2981
2982[dependencies]
2983"#,
2984 current_harn_range_example()
2985 ),
2986 )
2987 .unwrap();
2988 fs::write(
2989 tmp.path().join("skills/review/SKILL.md"),
2990 "---\nname: review\nshort: Review changes\n---\n# Review\n",
2991 )
2992 .unwrap();
2993
2994 let report = check_package_impl(Some(tmp.path())).unwrap();
2995
2996 assert!(report.errors.is_empty(), "{:?}", report.errors);
2997 assert_eq!(report.tools[0].name, "read-note");
2998 assert_eq!(
2999 report.tools[0].host_requirements,
3000 vec!["workspace.read_text"]
3001 );
3002 assert_eq!(report.skills[0].name, "review");
3003 }
3004
3005 #[test]
3006 fn package_check_rejects_invalid_tool_schema_and_host_requirement() {
3007 let tmp = tempfile::tempdir().unwrap();
3008 write_publishable_package(tmp.path());
3009 fs::write(
3010 tmp.path().join(MANIFEST),
3011 format!(
3012 r#"[package]
3013name = "acme-lib"
3014version = "0.1.0"
3015description = "Acme helpers"
3016license = "MIT"
3017repository = "https://github.com/acme/acme-lib"
3018harn = "{}"
3019docs_url = "docs/api.md"
3020
3021[exports]
3022lib = "lib/main.harn"
3023
3024[[package.tools]]
3025name = "broken"
3026module = "lib/main.harn"
3027symbol = "tools"
3028host_requirements = ["workspace"]
3029
3030[package.tools.input_schema]
3031required = [1]
3032
3033[dependencies]
3034"#,
3035 current_harn_range_example()
3036 ),
3037 )
3038 .unwrap();
3039
3040 let report = check_package_impl(Some(tmp.path())).unwrap();
3041 let messages = report
3042 .errors
3043 .iter()
3044 .map(|diagnostic| diagnostic.message.as_str())
3045 .collect::<Vec<_>>()
3046 .join("\n");
3047
3048 assert!(messages.contains("capability.operation"));
3049 assert!(messages.contains("schema `required` must be a list of strings"));
3050 }
3051
3052 #[test]
3053 fn package_doctor_accepts_application_manifests_with_tool_exports() {
3054 let tmp = tempfile::tempdir().unwrap();
3055 fs::write(
3056 tmp.path().join(MANIFEST),
3057 r#"[package]
3058name = "acme-app"
3059
3060[[package.tools]]
3061name = "echo"
3062module = "tools.harn"
3063symbol = "tools"
3064
3065[package.tools.input_schema]
3066type = "object"
3067
3068[package.tools.annotations]
3069kind = "read"
3070side_effect_level = "read_only"
3071"#,
3072 )
3073 .unwrap();
3074 fs::write(tmp.path().join("tools.harn"), "pub fn tools() {}\n").unwrap();
3075 let workspace = TestWorkspace::new(tmp.path());
3076
3077 let report = doctor_packages_in(workspace.env()).unwrap();
3078
3079 assert!(report.ok, "{:?}", report.diagnostics);
3080 assert!(
3081 report
3082 .diagnostics
3083 .iter()
3084 .all(|diagnostic| diagnostic.code != "root-package-check"),
3085 "{:?}",
3086 report.diagnostics
3087 );
3088 }
3089
3090 #[test]
3091 fn package_list_reports_locked_tool_and_skill_exports() {
3092 let tmp = tempfile::tempdir().unwrap();
3093 fs::write(
3094 tmp.path().join(MANIFEST),
3095 r#"[package]
3096name = "consumer"
3097"#,
3098 )
3099 .unwrap();
3100 let lock = LockFile {
3101 packages: vec![LockEntry {
3102 name: "acme-tools".to_string(),
3103 source: "path+../acme-tools".to_string(),
3104 package_version: Some("0.1.0".to_string()),
3105 provenance: Some(
3106 "https://github.com/acme/acme-tools/releases/tag/v0.1.0".to_string(),
3107 ),
3108 exports: PackageLockExports {
3109 modules: vec![PackageLockExport {
3110 name: "tools".to_string(),
3111 path: Some("lib/tools.harn".to_string()),
3112 symbol: None,
3113 }],
3114 tools: vec![PackageLockExport {
3115 name: "echo".to_string(),
3116 path: Some("lib/tools.harn".to_string()),
3117 symbol: Some("tools".to_string()),
3118 }],
3119 skills: vec![PackageLockExport {
3120 name: "review".to_string(),
3121 path: Some("skills/review".to_string()),
3122 symbol: None,
3123 }],
3124 personas: Vec::new(),
3125 },
3126 permissions: vec!["tool:read_only".to_string()],
3127 host_requirements: vec!["workspace.read_text".to_string()],
3128 ..LockEntry::default()
3129 }],
3130 ..LockFile::default()
3131 };
3132 let lock_body = toml::to_string_pretty(&lock).unwrap();
3133 fs::write(tmp.path().join(LOCK_FILE), lock_body).unwrap();
3134 let workspace = TestWorkspace::new(tmp.path());
3135
3136 let report = list_packages_in(workspace.env()).unwrap();
3137
3138 assert_eq!(report.packages.len(), 1);
3139 let package = &report.packages[0];
3140 assert_eq!(package.name, "acme-tools");
3141 assert_eq!(
3142 package.provenance.as_deref(),
3143 Some("https://github.com/acme/acme-tools/releases/tag/v0.1.0")
3144 );
3145 assert_eq!(package.exports.tools[0].name, "echo");
3146 assert_eq!(package.exports.skills[0].name, "review");
3147 assert_eq!(package.permissions, vec!["tool:read_only"]);
3148 assert_eq!(package.host_requirements, vec!["workspace.read_text"]);
3149 }
3150
3151 fn write_release_changelog(root: &Path, version: &str) {
3152 fs::write(
3153 root.join("CHANGELOG.md"),
3154 format!("# Changelog\n\n## {version}\n\n- Initial release.\n"),
3155 )
3156 .unwrap();
3157 }
3158
3159 fn init_publishable_repo(root: &Path) -> tempfile::TempDir {
3160 let init = test_git_command(root)
3161 .args(["init", "-b", "main"])
3162 .output()
3163 .unwrap();
3164 if !init.status.success() {
3165 run_git(root, &["init"]);
3166 }
3167 run_git(root, &["config", "user.email", "tests@example.com"]);
3168 run_git(root, &["config", "user.name", "Harn Tests"]);
3169 run_git(root, &["config", "core.hooksPath", "/dev/null"]);
3170 run_git(root, &["add", "."]);
3171 run_git(root, &["commit", "-m", "initial"]);
3172
3173 let remote = tempfile::tempdir().unwrap();
3174 let bare = remote.path().join("origin.git");
3175 let output = test_git_command(root)
3176 .args(["init", "--bare", bare.to_string_lossy().as_ref()])
3177 .output()
3178 .unwrap();
3179 assert!(
3180 output.status.success(),
3181 "git init --bare failed: {}",
3182 String::from_utf8_lossy(&output.stderr)
3183 );
3184 run_git(
3185 root,
3186 &["remote", "add", "origin", bare.to_string_lossy().as_ref()],
3187 );
3188 remote
3189 }
3190}