Skip to main content

harn_cli/package/
package_ops.rs

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