Skip to main content

harn_cli/package/
package_ops.rs

1use super::errors::PackageError;
2use super::*;
3
4#[derive(Debug, Clone, Serialize)]
5pub struct PackageCheckReport {
6    pub package_dir: String,
7    pub manifest_path: String,
8    pub name: Option<String>,
9    pub version: Option<String>,
10    pub errors: Vec<PackageCheckDiagnostic>,
11    pub warnings: Vec<PackageCheckDiagnostic>,
12    pub exports: Vec<PackageExportReport>,
13}
14
15#[derive(Debug, Clone, Serialize)]
16pub struct PackageCheckDiagnostic {
17    pub field: String,
18    pub message: String,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct PackageExportReport {
23    pub name: String,
24    pub path: String,
25    pub symbols: Vec<PackageApiSymbol>,
26}
27
28#[derive(Debug, Clone, Serialize)]
29pub struct PackageApiSymbol {
30    pub kind: String,
31    pub name: String,
32    pub signature: String,
33    pub docs: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct PackagePackReport {
38    pub package_dir: String,
39    pub artifact_dir: String,
40    pub dry_run: bool,
41    pub files: Vec<String>,
42    pub check: PackageCheckReport,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct PackagePublishReport {
47    pub dry_run: bool,
48    pub registry: String,
49    pub artifact_dir: String,
50    pub files: Vec<String>,
51    pub check: PackageCheckReport,
52}
53
54pub fn check_package(anchor: Option<&Path>, json: bool) {
55    match check_package_impl(anchor) {
56        Ok(report) => {
57            if json {
58                println!(
59                    "{}",
60                    serde_json::to_string_pretty(&report)
61                        .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
62                );
63            } else {
64                print_package_check_report(&report);
65            }
66            if !report.errors.is_empty() {
67                process::exit(1);
68            }
69        }
70        Err(error) => {
71            eprintln!("error: {error}");
72            process::exit(1);
73        }
74    }
75}
76
77pub fn pack_package(anchor: Option<&Path>, output: Option<&Path>, dry_run: bool, json: bool) {
78    match pack_package_impl(anchor, output, dry_run) {
79        Ok(report) => {
80            if json {
81                println!(
82                    "{}",
83                    serde_json::to_string_pretty(&report)
84                        .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
85                );
86            } else {
87                print_package_pack_report(&report);
88            }
89        }
90        Err(error) => {
91            eprintln!("error: {error}");
92            process::exit(1);
93        }
94    }
95}
96
97pub fn generate_package_docs(anchor: Option<&Path>, output: Option<&Path>, check: bool) {
98    match generate_package_docs_impl(anchor, output, check) {
99        Ok(path) if check => println!("{} is up to date.", path.display()),
100        Ok(path) => println!("Wrote {}.", path.display()),
101        Err(error) => {
102            eprintln!("error: {error}");
103            process::exit(1);
104        }
105    }
106}
107
108pub fn publish_package(anchor: Option<&Path>, dry_run: bool, registry: Option<&str>, json: bool) {
109    if !dry_run {
110        eprintln!(
111            "error: registry submission is not enabled yet; use `harn publish --dry-run` to validate the package and inspect the artifact"
112        );
113        process::exit(1);
114    }
115
116    match publish_package_impl(anchor, registry) {
117        Ok(report) => {
118            if json {
119                println!(
120                    "{}",
121                    serde_json::to_string_pretty(&report)
122                        .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
123                );
124            } else {
125                println!("Dry-run publish to {} succeeded.", report.registry);
126                println!("artifact: {}", report.artifact_dir);
127                println!("files: {}", report.files.len());
128            }
129        }
130        Err(error) => {
131            eprintln!("error: {error}");
132            process::exit(1);
133        }
134    }
135}
136
137pub(crate) fn check_package_impl(
138    anchor: Option<&Path>,
139) -> Result<PackageCheckReport, PackageError> {
140    let ctx = load_manifest_context_for_anchor(anchor)?;
141    let manifest_path = ctx.manifest_path();
142    let mut errors = Vec::new();
143    let mut warnings = Vec::new();
144
145    let package = ctx.manifest.package.as_ref();
146    let name = package.and_then(|package| package.name.clone());
147    let version = package.and_then(|package| package.version.clone());
148    let package_name = required_package_string(
149        package.and_then(|package| package.name.as_deref()),
150        "[package].name",
151        &mut errors,
152    );
153    if let Some(name) = package_name {
154        if let Err(message) = validate_package_alias(name) {
155            push_error(&mut errors, "[package].name", message);
156        }
157    }
158    required_package_string(
159        package.and_then(|package| package.version.as_deref()),
160        "[package].version",
161        &mut errors,
162    );
163    required_package_string(
164        package.and_then(|package| package.description.as_deref()),
165        "[package].description",
166        &mut errors,
167    );
168    required_package_string(
169        package.and_then(|package| package.license.as_deref()),
170        "[package].license",
171        &mut errors,
172    );
173    if !ctx.dir.join("README.md").is_file() {
174        push_error(&mut errors, "README.md", "package README.md is required");
175    }
176    if !ctx.dir.join("LICENSE").is_file() && package.and_then(|p| p.license.as_deref()).is_none() {
177        push_error(
178            &mut errors,
179            "[package].license",
180            "publishable packages require a license field or LICENSE file",
181        );
182    }
183
184    validate_optional_url(
185        package.and_then(|package| package.repository.as_deref()),
186        "[package].repository",
187        &mut errors,
188    );
189    validate_docs_url(
190        &ctx.dir,
191        package.and_then(|package| package.docs_url.as_deref()),
192        &mut errors,
193        &mut warnings,
194    );
195    match package.and_then(|package| package.harn.as_deref()) {
196        Some(range) if supports_current_harn(range) => {}
197        Some(range) => push_error(
198            &mut errors,
199            "[package].harn",
200            format!(
201                "unsupported Harn version range '{range}'; include the current {} line, for example {}",
202                current_harn_line_label(),
203                current_harn_range_example()
204            ),
205        ),
206        None => push_error(
207            &mut errors,
208            "[package].harn",
209            format!(
210                "missing Harn compatibility metadata; add harn = \"{}\"",
211                current_harn_range_example()
212            ),
213        ),
214    }
215
216    validate_dependencies_for_publish(&ctx, &mut errors, &mut warnings);
217    if let Err(error) = validate_handoff_routes(&ctx.manifest.handoff_routes, &ctx.manifest) {
218        push_error(&mut errors, "handoff_routes", error.to_string());
219    }
220    let exports = validate_exports_for_publish(&ctx, &mut errors, &mut warnings);
221
222    Ok(PackageCheckReport {
223        package_dir: ctx.dir.display().to_string(),
224        manifest_path: manifest_path.display().to_string(),
225        name,
226        version,
227        errors,
228        warnings,
229        exports,
230    })
231}
232
233pub(crate) fn pack_package_impl(
234    anchor: Option<&Path>,
235    output: Option<&Path>,
236    dry_run: bool,
237) -> Result<PackagePackReport, PackageError> {
238    let report = check_package_impl(anchor)?;
239    fail_if_package_errors(&report)?;
240    let ctx = load_manifest_context_for_anchor(anchor)?;
241    let files = collect_package_files(&ctx.dir)?;
242    let artifact_dir = output
243        .map(Path::to_path_buf)
244        .unwrap_or_else(|| default_artifact_dir(&ctx, &report));
245
246    if !dry_run {
247        if artifact_dir.exists() {
248            return Err(
249                format!("artifact output {} already exists", artifact_dir.display()).into(),
250            );
251        }
252        fs::create_dir_all(&artifact_dir)
253            .map_err(|error| format!("failed to create {}: {error}", artifact_dir.display()))?;
254        for rel in &files {
255            let src = ctx.dir.join(rel);
256            let dst = artifact_dir.join(rel);
257            if let Some(parent) = dst.parent() {
258                fs::create_dir_all(parent)
259                    .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
260            }
261            fs::copy(&src, &dst)
262                .map_err(|error| format!("failed to copy {}: {error}", src.display()))?;
263        }
264        let manifest_path = artifact_dir.join(".harn-package-manifest.json");
265        let manifest_body = serde_json::to_string_pretty(&report)
266            .map_err(|error| format!("failed to render package manifest: {error}"))?
267            + "\n";
268        harn_vm::atomic_io::atomic_write(&manifest_path, manifest_body.as_bytes())
269            .map_err(|error| format!("failed to write {}: {error}", manifest_path.display()))?;
270    }
271
272    Ok(PackagePackReport {
273        package_dir: ctx.dir.display().to_string(),
274        artifact_dir: artifact_dir.display().to_string(),
275        dry_run,
276        files,
277        check: report,
278    })
279}
280
281pub(crate) fn generate_package_docs_impl(
282    anchor: Option<&Path>,
283    output: Option<&Path>,
284    check: bool,
285) -> Result<PathBuf, PackageError> {
286    let report = check_package_impl(anchor)?;
287    let ctx = load_manifest_context_for_anchor(anchor)?;
288    let output_path = output
289        .map(Path::to_path_buf)
290        .unwrap_or_else(|| ctx.dir.join("docs").join("api.md"));
291    let rendered = render_package_api_docs(&report);
292    if check {
293        let existing = fs::read_to_string(&output_path)
294            .map_err(|error| format!("failed to read {}: {error}", output_path.display()))?;
295        if normalize_newlines(&existing) != normalize_newlines(&rendered) {
296            return Err(format!(
297                "{} is stale; run `harn package docs`",
298                output_path.display()
299            )
300            .into());
301        }
302        return Ok(output_path);
303    }
304    harn_vm::atomic_io::atomic_write(&output_path, rendered.as_bytes())
305        .map_err(|error| format!("failed to write {}: {error}", output_path.display()))?;
306    Ok(output_path)
307}
308
309pub(crate) fn publish_package_impl(
310    anchor: Option<&Path>,
311    registry: Option<&str>,
312) -> Result<PackagePublishReport, PackageError> {
313    let pack = pack_package_impl(anchor, None, true)?;
314    let registry = resolve_configured_registry_source(registry)?;
315    Ok(PackagePublishReport {
316        dry_run: true,
317        registry,
318        artifact_dir: pack.artifact_dir,
319        files: pack.files,
320        check: pack.check,
321    })
322}
323
324pub(crate) fn load_manifest_context_for_anchor(
325    anchor: Option<&Path>,
326) -> Result<ManifestContext, PackageError> {
327    let anchor = anchor
328        .map(Path::to_path_buf)
329        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
330    let manifest_path = if anchor.is_dir() {
331        anchor.join(MANIFEST)
332    } else if anchor.file_name() == Some(OsStr::new(MANIFEST)) {
333        anchor.clone()
334    } else {
335        let (_, dir) = find_nearest_manifest(&anchor)
336            .ok_or_else(|| format!("no {MANIFEST} found from {}", anchor.display()))?;
337        dir.join(MANIFEST)
338    };
339    let manifest = read_manifest_from_path(&manifest_path)?;
340    let dir = manifest_path
341        .parent()
342        .map(Path::to_path_buf)
343        .unwrap_or_else(|| PathBuf::from("."));
344    Ok(ManifestContext { manifest, dir })
345}
346
347pub(crate) fn required_package_string<'a>(
348    value: Option<&'a str>,
349    field: &str,
350    errors: &mut Vec<PackageCheckDiagnostic>,
351) -> Option<&'a str> {
352    match value.map(str::trim).filter(|value| !value.is_empty()) {
353        Some(value) => Some(value),
354        None => {
355            push_error(errors, field, format!("missing required {field}"));
356            None
357        }
358    }
359}
360
361pub(crate) fn push_error(
362    diagnostics: &mut Vec<PackageCheckDiagnostic>,
363    field: impl Into<String>,
364    message: impl Into<String>,
365) {
366    diagnostics.push(PackageCheckDiagnostic {
367        field: field.into(),
368        message: message.into(),
369    });
370}
371
372pub(crate) fn push_warning(
373    diagnostics: &mut Vec<PackageCheckDiagnostic>,
374    field: impl Into<String>,
375    message: impl Into<String>,
376) {
377    push_error(diagnostics, field, message);
378}
379
380pub(crate) fn validate_optional_url(
381    value: Option<&str>,
382    field: &str,
383    errors: &mut Vec<PackageCheckDiagnostic>,
384) {
385    let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
386        push_error(errors, field, format!("missing required {field}"));
387        return;
388    };
389    if Url::parse(value).is_err() {
390        push_error(errors, field, format!("{field} must be an absolute URL"));
391    }
392}
393
394pub(crate) fn validate_docs_url(
395    root: &Path,
396    value: Option<&str>,
397    errors: &mut Vec<PackageCheckDiagnostic>,
398    warnings: &mut Vec<PackageCheckDiagnostic>,
399) {
400    let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
401        push_warning(
402            warnings,
403            "[package].docs_url",
404            "missing docs_url; `harn package docs` defaults to docs/api.md",
405        );
406        return;
407    };
408    if Url::parse(value).is_ok() {
409        return;
410    }
411    let path = PathBuf::from(value);
412    let path = if path.is_absolute() {
413        path
414    } else {
415        root.join(path)
416    };
417    if !path.exists() {
418        push_error(
419            errors,
420            "[package].docs_url",
421            format!("docs_url path {} does not exist", path.display()),
422        );
423    }
424}
425
426pub(crate) fn validate_dependencies_for_publish(
427    ctx: &ManifestContext,
428    errors: &mut Vec<PackageCheckDiagnostic>,
429    warnings: &mut Vec<PackageCheckDiagnostic>,
430) {
431    let mut aliases = BTreeSet::new();
432    for (alias, dependency) in &ctx.manifest.dependencies {
433        let field = format!("[dependencies].{alias}");
434        if let Err(message) = validate_package_alias(alias) {
435            push_error(errors, &field, message);
436        }
437        if !aliases.insert(alias) {
438            push_error(errors, &field, "duplicate dependency alias");
439        }
440        match dependency {
441            Dependency::Path(path) => push_error(
442                errors,
443                &field,
444                format!("path-only dependency '{path}' is not publishable; pin a git rev or registry version"),
445            ),
446            Dependency::Table(table) => {
447                if table.path.is_some() {
448                    push_error(
449                        errors,
450                        &field,
451                        "path dependencies are not publishable; pin a git rev or registry version",
452                    );
453                }
454                if table.git.is_none() && table.path.is_none() {
455                    push_error(errors, &field, "dependency must specify git, registry-expanded git, or path");
456                }
457                if table.rev.is_some() && table.branch.is_some() {
458                    push_error(errors, &field, "dependency cannot specify both rev and branch");
459                }
460                if table.git.is_some() && table.rev.is_none() && table.branch.is_none() {
461                    push_error(errors, &field, "git dependency must specify rev or branch");
462                }
463                if table.branch.is_some() {
464                    push_warning(
465                        warnings,
466                        &field,
467                        "branch dependencies are allowed but rev pins are more reproducible for publishing",
468                    );
469                }
470                if let Some(git) = table.git.as_deref() {
471                    if normalize_git_url(git).is_err() {
472                        push_error(errors, &field, format!("invalid git source '{git}'"));
473                    }
474                }
475            }
476        }
477    }
478}
479
480pub(crate) fn validate_exports_for_publish(
481    ctx: &ManifestContext,
482    errors: &mut Vec<PackageCheckDiagnostic>,
483    warnings: &mut Vec<PackageCheckDiagnostic>,
484) -> Vec<PackageExportReport> {
485    if ctx.manifest.exports.is_empty() {
486        push_error(
487            errors,
488            "[exports]",
489            "publishable packages require at least one stable export",
490        );
491        return Vec::new();
492    }
493
494    let mut exports = Vec::new();
495    for (name, rel_path) in &ctx.manifest.exports {
496        let field = format!("[exports].{name}");
497        if let Err(message) = validate_package_alias(name) {
498            push_error(errors, &field, message);
499        }
500        let Ok(path) = safe_package_relative_path(&ctx.dir, rel_path) else {
501            push_error(
502                errors,
503                &field,
504                "export path must stay inside the package directory",
505            );
506            continue;
507        };
508        if path.extension() != Some(OsStr::new("harn")) {
509            push_error(errors, &field, "export path must point at a .harn file");
510            continue;
511        }
512        let content = match fs::read_to_string(&path) {
513            Ok(content) => content,
514            Err(error) => {
515                push_error(
516                    errors,
517                    &field,
518                    format!("failed to read export {}: {error}", path.display()),
519                );
520                continue;
521            }
522        };
523        if let Err(error) = parse_harn_source(&content) {
524            push_error(errors, &field, format!("failed to parse export: {error}"));
525        }
526        let symbols = extract_api_symbols(&content);
527        if symbols.is_empty() {
528            push_warning(
529                warnings,
530                &field,
531                "exported module has no public symbols to document",
532            );
533        }
534        for symbol in &symbols {
535            if symbol.docs.is_none() {
536                push_warning(
537                    warnings,
538                    &field,
539                    format!(
540                        "public {} '{}' has no doc comment",
541                        symbol.kind, symbol.name
542                    ),
543                );
544            }
545        }
546        exports.push(PackageExportReport {
547            name: name.clone(),
548            path: rel_path.clone(),
549            symbols,
550        });
551    }
552    exports.sort_by(|left, right| left.name.cmp(&right.name));
553    exports
554}
555
556pub(crate) fn parse_harn_source(source: &str) -> Result<(), PackageError> {
557    let mut lexer = harn_lexer::Lexer::new(source);
558    let tokens = lexer.tokenize().map_err(|error| error.to_string())?;
559    let mut parser = harn_parser::Parser::new(tokens);
560    parser
561        .parse()
562        .map(|_| ())
563        .map_err(|error| PackageError::Ops(error.to_string()))
564}
565
566pub(crate) fn safe_package_relative_path(
567    root: &Path,
568    rel_path: &str,
569) -> Result<PathBuf, PackageError> {
570    let rel = PathBuf::from(rel_path);
571    if rel.is_absolute()
572        || rel
573            .components()
574            .any(|component| matches!(component, std::path::Component::ParentDir))
575    {
576        return Err(format!("path {rel_path:?} escapes package root").into());
577    }
578    Ok(root.join(rel))
579}
580
581pub(crate) fn extract_api_symbols(source: &str) -> Vec<PackageApiSymbol> {
582    static DECL_RE: OnceLock<Regex> = OnceLock::new();
583    let decl_re = DECL_RE.get_or_init(|| {
584        Regex::new(r"^\s*pub\s+(fn|pipeline|tool|skill|struct|enum|type|interface)\s+([A-Za-z_][A-Za-z0-9_]*)\b(.*)$")
585            .expect("valid declaration regex")
586    });
587    let mut docs: Vec<String> = Vec::new();
588    let mut symbols = Vec::new();
589    for line in source.lines() {
590        let trimmed = line.trim();
591        if let Some(doc) = trimmed.strip_prefix("///") {
592            docs.push(doc.trim().to_string());
593            continue;
594        }
595        if trimmed.is_empty() {
596            continue;
597        }
598        if let Some(captures) = decl_re.captures(line) {
599            let kind = captures.get(1).expect("kind").as_str().to_string();
600            let name = captures.get(2).expect("name").as_str().to_string();
601            let signature = trim_signature(line);
602            let doc_text = (!docs.is_empty()).then(|| docs.join("\n"));
603            symbols.push(PackageApiSymbol {
604                kind,
605                name,
606                signature,
607                docs: doc_text,
608            });
609        }
610        docs.clear();
611    }
612    symbols
613}
614
615pub(crate) fn trim_signature(line: &str) -> String {
616    let mut signature = line.trim().to_string();
617    if let Some((before, _)) = signature.split_once('{') {
618        signature = before.trim_end().to_string();
619    }
620    signature
621}
622
623pub(crate) fn supports_current_harn(range: &str) -> bool {
624    let current = env!("CARGO_PKG_VERSION");
625    let Some((major, minor)) = parse_major_minor(current) else {
626        return true;
627    };
628    let range = range.trim();
629    if range.is_empty() {
630        return false;
631    }
632    if let Some(rest) = range.strip_prefix('^') {
633        return parse_major_minor(rest).is_some_and(|(m, n)| m == major && n == minor);
634    }
635    if !range.contains([',', '<', '>', '=']) {
636        return parse_major_minor(range).is_some_and(|(m, n)| m == major && n == minor);
637    }
638
639    let current_value = major * 1000 + minor;
640    let mut lower_ok = true;
641    let mut upper_ok = true;
642    let mut saw_constraint = false;
643    for raw in range.split(',') {
644        let part = raw.trim();
645        if part.is_empty() {
646            continue;
647        }
648        saw_constraint = true;
649        if let Some(rest) = part.strip_prefix(">=") {
650            if let Some((m, n)) = parse_major_minor(rest.trim()) {
651                lower_ok &= current_value >= m * 1000 + n;
652            } else {
653                return false;
654            }
655        } else if let Some(rest) = part.strip_prefix('>') {
656            if let Some((m, n)) = parse_major_minor(rest.trim()) {
657                lower_ok &= current_value > m * 1000 + n;
658            } else {
659                return false;
660            }
661        } else if let Some(rest) = part.strip_prefix("<=") {
662            if let Some((m, n)) = parse_major_minor(rest.trim()) {
663                upper_ok &= current_value <= m * 1000 + n;
664            } else {
665                return false;
666            }
667        } else if let Some(rest) = part.strip_prefix('<') {
668            if let Some((m, n)) = parse_major_minor(rest.trim()) {
669                upper_ok &= current_value < m * 1000 + n;
670            } else {
671                return false;
672            }
673        } else if let Some(rest) = part.strip_prefix('=') {
674            if let Some((m, n)) = parse_major_minor(rest.trim()) {
675                lower_ok &= current_value == m * 1000 + n;
676                upper_ok &= current_value == m * 1000 + n;
677            } else {
678                return false;
679            }
680        } else {
681            return false;
682        }
683    }
684    saw_constraint && lower_ok && upper_ok
685}
686
687pub(crate) fn current_harn_range_example() -> String {
688    let current = env!("CARGO_PKG_VERSION");
689    let Some((major, minor)) = parse_major_minor(current) else {
690        return ">=0.7,<0.8".to_string();
691    };
692    format!(">={major}.{minor},<{major}.{}", minor + 1)
693}
694
695pub(crate) fn current_harn_line_label() -> String {
696    let current = env!("CARGO_PKG_VERSION");
697    let Some((major, minor)) = parse_major_minor(current) else {
698        return "0.7".to_string();
699    };
700    format!("{major}.{minor}")
701}
702
703pub(crate) fn parse_major_minor(raw: &str) -> Option<(u64, u64)> {
704    let raw = raw.trim().trim_start_matches('v');
705    let mut parts = raw.split('.');
706    let major = parts.next()?.parse().ok()?;
707    let minor = parts.next()?.trim_end_matches('x').parse().ok()?;
708    Some((major, minor))
709}
710
711pub(crate) fn collect_package_files(root: &Path) -> Result<Vec<String>, PackageError> {
712    let mut files = Vec::new();
713    collect_package_files_inner(root, root, &mut files)?;
714    files.sort();
715    Ok(files)
716}
717
718pub(crate) fn collect_package_files_inner(
719    root: &Path,
720    dir: &Path,
721    out: &mut Vec<String>,
722) -> Result<(), PackageError> {
723    for entry in
724        fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?
725    {
726        let entry =
727            entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?;
728        let path = entry.path();
729        let name = entry.file_name();
730        if path.is_dir() {
731            if should_skip_package_dir(&name) {
732                continue;
733            }
734            collect_package_files_inner(root, &path, out)?;
735        } else if path.is_file() {
736            let rel = path
737                .strip_prefix(root)
738                .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?
739                .to_string_lossy()
740                .replace('\\', "/");
741            out.push(rel);
742        }
743    }
744    Ok(())
745}
746
747pub(crate) fn should_skip_package_dir(name: &OsStr) -> bool {
748    matches!(
749        name.to_str(),
750        Some(".git" | ".harn" | "target" | "node_modules" | "docs/dist")
751    )
752}
753
754pub(crate) fn default_artifact_dir(ctx: &ManifestContext, report: &PackageCheckReport) -> PathBuf {
755    let name = report.name.as_deref().unwrap_or("package");
756    let version = report.version.as_deref().unwrap_or("0.0.0");
757    ctx.dir
758        .join(".harn")
759        .join("dist")
760        .join(format!("{name}-{version}"))
761}
762
763pub(crate) fn fail_if_package_errors(report: &PackageCheckReport) -> Result<(), PackageError> {
764    if report.errors.is_empty() {
765        return Ok(());
766    }
767    Err(format!(
768        "package check failed:\n{}",
769        report
770            .errors
771            .iter()
772            .map(|diagnostic| format!("- {}: {}", diagnostic.field, diagnostic.message))
773            .collect::<Vec<_>>()
774            .join("\n")
775    )
776    .into())
777}
778
779pub(crate) fn render_package_api_docs(report: &PackageCheckReport) -> String {
780    let title = report.name.as_deref().unwrap_or("package");
781    let mut out = format!("# API Reference: {title}\n\nGenerated by `harn package docs`.\n");
782    if let Some(version) = report.version.as_deref() {
783        out.push_str(&format!("\nVersion: `{version}`\n"));
784    }
785    for export in &report.exports {
786        out.push_str(&format!(
787            "\n## Export `{}`\n\n`{}`\n",
788            export.name, export.path
789        ));
790        for symbol in &export.symbols {
791            out.push_str(&format!("\n### {} `{}`\n\n", symbol.kind, symbol.name));
792            if let Some(docs) = symbol.docs.as_deref() {
793                out.push_str(docs);
794                out.push_str("\n\n");
795            }
796            out.push_str("```harn\n");
797            out.push_str(&symbol.signature);
798            out.push_str("\n```\n");
799        }
800    }
801    out
802}
803
804pub(crate) fn normalize_newlines(input: &str) -> String {
805    input.replace("\r\n", "\n")
806}
807
808pub(crate) fn print_package_check_report(report: &PackageCheckReport) {
809    println!(
810        "Package {} {}",
811        report.name.as_deref().unwrap_or("<unnamed>"),
812        report.version.as_deref().unwrap_or("<unversioned>")
813    );
814    println!("manifest: {}", report.manifest_path);
815    for export in &report.exports {
816        println!(
817            "export {} -> {} ({} public symbol(s))",
818            export.name,
819            export.path,
820            export.symbols.len()
821        );
822    }
823    if !report.warnings.is_empty() {
824        println!("\nwarnings:");
825        for warning in &report.warnings {
826            println!("- {}: {}", warning.field, warning.message);
827        }
828    }
829    if !report.errors.is_empty() {
830        println!("\nerrors:");
831        for error in &report.errors {
832            println!("- {}: {}", error.field, error.message);
833        }
834    } else {
835        println!("\npackage check passed");
836    }
837}
838
839pub(crate) fn print_package_pack_report(report: &PackagePackReport) {
840    if report.dry_run {
841        println!("Package pack dry run succeeded.");
842    } else {
843        println!("Packed package artifact.");
844    }
845    println!("artifact: {}", report.artifact_dir);
846    println!("files:");
847    for file in &report.files {
848        println!("- {file}");
849    }
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855    use crate::package::test_support::*;
856
857    #[test]
858    fn package_check_accepts_publishable_package() {
859        let tmp = tempfile::tempdir().unwrap();
860        write_publishable_package(tmp.path());
861
862        let report = check_package_impl(Some(tmp.path())).unwrap();
863
864        assert!(report.errors.is_empty(), "{:?}", report.errors);
865        assert_eq!(report.name.as_deref(), Some("acme-lib"));
866        assert_eq!(report.exports[0].symbols[0].name, "greet");
867    }
868
869    #[test]
870    fn package_check_rejects_path_dependencies_and_bad_harn_range() {
871        let tmp = tempfile::tempdir().unwrap();
872        write_publishable_package(tmp.path());
873        fs::write(
874            tmp.path().join(MANIFEST),
875            r#"[package]
876    name = "acme-lib"
877    version = "0.1.0"
878    description = "Acme helpers"
879    license = "MIT"
880    repository = "https://github.com/acme/acme-lib"
881    harn = ">=999.0,<999.1"
882    docs_url = "docs/api.md"
883
884    [exports]
885    lib = "lib/main.harn"
886
887    [dependencies]
888    local = { path = "../local" }
889    "#,
890        )
891        .unwrap();
892
893        let report = check_package_impl(Some(tmp.path())).unwrap();
894        let messages = report
895            .errors
896            .iter()
897            .map(|diagnostic| diagnostic.message.as_str())
898            .collect::<Vec<_>>()
899            .join("\n");
900
901        assert!(messages.contains("unsupported Harn version range"));
902        assert!(messages.contains("path dependencies are not publishable"));
903    }
904
905    #[test]
906    fn package_docs_and_pack_use_exports() {
907        let tmp = tempfile::tempdir().unwrap();
908        write_publishable_package(tmp.path());
909
910        let docs_path = generate_package_docs_impl(Some(tmp.path()), None, false).unwrap();
911        let docs = fs::read_to_string(docs_path).unwrap();
912        assert!(docs.contains("### fn `greet`"));
913        assert!(docs.contains("Return a greeting."));
914
915        let pack = pack_package_impl(Some(tmp.path()), None, true).unwrap();
916        assert!(pack.files.contains(&"harn.toml".to_string()));
917        assert!(pack.files.contains(&"lib/main.harn".to_string()));
918    }
919}