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