Skip to main content

harn_cli/package/
package_ops.rs

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