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}