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