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 let mut in_block_doc = false;
590 for line in source.lines() {
591 let trimmed = line.trim();
592 if in_block_doc {
593 let (content, closes) = match trimmed.split_once("*/") {
597 Some((before, _)) => (before, true),
598 None => (trimmed, false),
599 };
600 let stripped = content
601 .strip_prefix("* ")
602 .or_else(|| content.strip_prefix('*'))
603 .unwrap_or(content)
604 .trim();
605 if !stripped.is_empty() {
606 docs.push(stripped.to_string());
607 }
608 if closes {
609 in_block_doc = false;
610 }
611 continue;
612 }
613 if let Some(doc) = trimmed.strip_prefix("///") {
614 docs.push(doc.trim().to_string());
615 continue;
616 }
617 if let Some(rest) = trimmed.strip_prefix("/**") {
618 if let Some((inner, _)) = rest.split_once("*/") {
622 let stripped = inner.trim();
623 if !stripped.is_empty() {
624 docs.push(stripped.to_string());
625 }
626 } else {
627 let stripped = rest.trim();
628 if !stripped.is_empty() {
629 docs.push(stripped.to_string());
630 }
631 in_block_doc = true;
632 }
633 continue;
634 }
635 if trimmed.is_empty() {
636 continue;
637 }
638 if let Some(captures) = decl_re.captures(line) {
639 let kind = captures.get(1).expect("kind").as_str().to_string();
640 let name = captures.get(2).expect("name").as_str().to_string();
641 let signature = trim_signature(line);
642 let doc_text = (!docs.is_empty()).then(|| docs.join("\n"));
643 symbols.push(PackageApiSymbol {
644 kind,
645 name,
646 signature,
647 docs: doc_text,
648 });
649 }
650 docs.clear();
651 }
652 symbols
653}
654
655pub(crate) fn trim_signature(line: &str) -> String {
656 let mut signature = line.trim().to_string();
657 if let Some((before, _)) = signature.split_once('{') {
658 signature = before.trim_end().to_string();
659 }
660 signature
661}
662
663pub(crate) fn supports_current_harn(range: &str) -> bool {
664 let current = env!("CARGO_PKG_VERSION");
665 let Some((major, minor)) = parse_major_minor(current) else {
666 return true;
667 };
668 let range = range.trim();
669 if range.is_empty() {
670 return false;
671 }
672 if let Some(rest) = range.strip_prefix('^') {
673 return parse_major_minor(rest).is_some_and(|(m, n)| m == major && n == minor);
674 }
675 if !range.contains([',', '<', '>', '=']) {
676 return parse_major_minor(range).is_some_and(|(m, n)| m == major && n == minor);
677 }
678
679 let current_value = major * 1000 + minor;
680 let mut lower_ok = true;
681 let mut upper_ok = true;
682 let mut saw_constraint = false;
683 for raw in range.split(',') {
684 let part = raw.trim();
685 if part.is_empty() {
686 continue;
687 }
688 saw_constraint = true;
689 if let Some(rest) = part.strip_prefix(">=") {
690 if let Some((m, n)) = parse_major_minor(rest.trim()) {
691 lower_ok &= current_value >= m * 1000 + n;
692 } else {
693 return false;
694 }
695 } else if let Some(rest) = part.strip_prefix('>') {
696 if let Some((m, n)) = parse_major_minor(rest.trim()) {
697 lower_ok &= current_value > m * 1000 + n;
698 } else {
699 return false;
700 }
701 } else if let Some(rest) = part.strip_prefix("<=") {
702 if let Some((m, n)) = parse_major_minor(rest.trim()) {
703 upper_ok &= current_value <= m * 1000 + n;
704 } else {
705 return false;
706 }
707 } else if let Some(rest) = part.strip_prefix('<') {
708 if let Some((m, n)) = parse_major_minor(rest.trim()) {
709 upper_ok &= current_value < m * 1000 + n;
710 } else {
711 return false;
712 }
713 } else if let Some(rest) = part.strip_prefix('=') {
714 if let Some((m, n)) = parse_major_minor(rest.trim()) {
715 lower_ok &= current_value == m * 1000 + n;
716 upper_ok &= current_value == m * 1000 + n;
717 } else {
718 return false;
719 }
720 } else {
721 return false;
722 }
723 }
724 saw_constraint && lower_ok && upper_ok
725}
726
727pub(crate) fn current_harn_range_example() -> String {
728 let current = env!("CARGO_PKG_VERSION");
729 let Some((major, minor)) = parse_major_minor(current) else {
730 return ">=0.7,<0.8".to_string();
731 };
732 format!(">={major}.{minor},<{major}.{}", minor + 1)
733}
734
735pub(crate) fn current_harn_line_label() -> String {
736 let current = env!("CARGO_PKG_VERSION");
737 let Some((major, minor)) = parse_major_minor(current) else {
738 return "0.7".to_string();
739 };
740 format!("{major}.{minor}")
741}
742
743pub(crate) fn parse_major_minor(raw: &str) -> Option<(u64, u64)> {
744 let raw = raw.trim().trim_start_matches('v');
745 let mut parts = raw.split('.');
746 let major = parts.next()?.parse().ok()?;
747 let minor = parts.next()?.trim_end_matches('x').parse().ok()?;
748 Some((major, minor))
749}
750
751pub(crate) fn collect_package_files(root: &Path) -> Result<Vec<String>, PackageError> {
752 let mut files = Vec::new();
753 collect_package_files_inner(root, root, &mut files)?;
754 files.sort();
755 Ok(files)
756}
757
758pub(crate) fn collect_package_files_inner(
759 root: &Path,
760 dir: &Path,
761 out: &mut Vec<String>,
762) -> Result<(), PackageError> {
763 for entry in
764 fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?
765 {
766 let entry =
767 entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?;
768 let path = entry.path();
769 let name = entry.file_name();
770 if path.is_dir() {
771 if should_skip_package_dir(&name) {
772 continue;
773 }
774 collect_package_files_inner(root, &path, out)?;
775 } else if path.is_file() {
776 let rel = path
777 .strip_prefix(root)
778 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?
779 .to_string_lossy()
780 .replace('\\', "/");
781 out.push(rel);
782 }
783 }
784 Ok(())
785}
786
787pub(crate) fn should_skip_package_dir(name: &OsStr) -> bool {
788 matches!(
789 name.to_str(),
790 Some(".git" | ".harn" | "target" | "node_modules" | "docs/dist")
791 )
792}
793
794pub(crate) fn default_artifact_dir(ctx: &ManifestContext, report: &PackageCheckReport) -> PathBuf {
795 let name = report.name.as_deref().unwrap_or("package");
796 let version = report.version.as_deref().unwrap_or("0.0.0");
797 ctx.dir
798 .join(".harn")
799 .join("dist")
800 .join(format!("{name}-{version}"))
801}
802
803pub(crate) fn fail_if_package_errors(report: &PackageCheckReport) -> Result<(), PackageError> {
804 if report.errors.is_empty() {
805 return Ok(());
806 }
807 Err(format!(
808 "package check failed:\n{}",
809 report
810 .errors
811 .iter()
812 .map(|diagnostic| format!("- {}: {}", diagnostic.field, diagnostic.message))
813 .collect::<Vec<_>>()
814 .join("\n")
815 )
816 .into())
817}
818
819pub(crate) fn render_package_api_docs(report: &PackageCheckReport) -> String {
820 let title = report.name.as_deref().unwrap_or("package");
821 let mut out = format!("# API Reference: {title}\n\nGenerated by `harn package docs`.\n");
822 if let Some(version) = report.version.as_deref() {
823 out.push_str(&format!("\nVersion: `{version}`\n"));
824 }
825 for export in &report.exports {
826 out.push_str(&format!(
827 "\n## Export `{}`\n\n`{}`\n",
828 export.name, export.path
829 ));
830 for symbol in &export.symbols {
831 out.push_str(&format!("\n### {} `{}`\n\n", symbol.kind, symbol.name));
832 if let Some(docs) = symbol.docs.as_deref() {
833 out.push_str(docs);
834 out.push_str("\n\n");
835 }
836 out.push_str("```harn\n");
837 out.push_str(&symbol.signature);
838 out.push_str("\n```\n");
839 }
840 }
841 out
842}
843
844pub(crate) fn normalize_newlines(input: &str) -> String {
845 input.replace("\r\n", "\n")
846}
847
848pub(crate) fn print_package_check_report(report: &PackageCheckReport) {
849 println!(
850 "Package {} {}",
851 report.name.as_deref().unwrap_or("<unnamed>"),
852 report.version.as_deref().unwrap_or("<unversioned>")
853 );
854 println!("manifest: {}", report.manifest_path);
855 for export in &report.exports {
856 println!(
857 "export {} -> {} ({} public symbol(s))",
858 export.name,
859 export.path,
860 export.symbols.len()
861 );
862 }
863 if !report.warnings.is_empty() {
864 println!("\nwarnings:");
865 for warning in &report.warnings {
866 println!("- {}: {}", warning.field, warning.message);
867 }
868 }
869 if !report.errors.is_empty() {
870 println!("\nerrors:");
871 for error in &report.errors {
872 println!("- {}: {}", error.field, error.message);
873 }
874 } else {
875 println!("\npackage check passed");
876 }
877}
878
879pub(crate) fn print_package_pack_report(report: &PackagePackReport) {
880 if report.dry_run {
881 println!("Package pack dry run succeeded.");
882 } else {
883 println!("Packed package artifact.");
884 }
885 println!("artifact: {}", report.artifact_dir);
886 println!("files:");
887 for file in &report.files {
888 println!("- {file}");
889 }
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895 use crate::package::test_support::*;
896
897 #[test]
898 fn package_check_accepts_publishable_package() {
899 let tmp = tempfile::tempdir().unwrap();
900 write_publishable_package(tmp.path());
901
902 let report = check_package_impl(Some(tmp.path())).unwrap();
903
904 assert!(report.errors.is_empty(), "{:?}", report.errors);
905 assert_eq!(report.name.as_deref(), Some("acme-lib"));
906 assert_eq!(report.exports[0].symbols[0].name, "greet");
907 }
908
909 #[test]
910 fn package_check_rejects_path_dependencies_and_bad_harn_range() {
911 let tmp = tempfile::tempdir().unwrap();
912 write_publishable_package(tmp.path());
913 fs::write(
914 tmp.path().join(MANIFEST),
915 r#"[package]
916 name = "acme-lib"
917 version = "0.1.0"
918 description = "Acme helpers"
919 license = "MIT"
920 repository = "https://github.com/acme/acme-lib"
921 harn = ">=999.0,<999.1"
922 docs_url = "docs/api.md"
923
924 [exports]
925 lib = "lib/main.harn"
926
927 [dependencies]
928 local = { path = "../local" }
929 "#,
930 )
931 .unwrap();
932
933 let report = check_package_impl(Some(tmp.path())).unwrap();
934 let messages = report
935 .errors
936 .iter()
937 .map(|diagnostic| diagnostic.message.as_str())
938 .collect::<Vec<_>>()
939 .join("\n");
940
941 assert!(messages.contains("unsupported Harn version range"));
942 assert!(messages.contains("path dependencies are not publishable"));
943 }
944
945 #[test]
946 fn extract_api_symbols_recognizes_block_doc_comments() {
947 let single = extract_api_symbols("/** Block doc. */\npub fn one() {}\n");
952 assert_eq!(single.len(), 1);
953 assert_eq!(single[0].docs.as_deref(), Some("Block doc."));
954
955 let multi =
956 extract_api_symbols("/**\n * First line.\n * Second line.\n */\npub fn two() {}\n");
957 assert_eq!(multi.len(), 1);
958 assert_eq!(multi[0].docs.as_deref(), Some("First line.\nSecond line."));
959
960 let triple = extract_api_symbols("/// Slash doc.\npub fn three() {}\n");
961 assert_eq!(triple.len(), 1);
962 assert_eq!(triple[0].docs.as_deref(), Some("Slash doc."));
963
964 let detached = extract_api_symbols("/** Detached. */\nlet x = 1\npub fn four() {}\n");
968 assert_eq!(detached.len(), 1);
969 assert!(detached[0].docs.is_none());
970 }
971
972 #[test]
973 fn package_docs_and_pack_use_exports() {
974 let tmp = tempfile::tempdir().unwrap();
975 write_publishable_package(tmp.path());
976
977 let docs_path = generate_package_docs_impl(Some(tmp.path()), None, false).unwrap();
978 let docs = fs::read_to_string(docs_path).unwrap();
979 assert!(docs.contains("### fn `greet`"));
980 assert!(docs.contains("Return a greeting."));
981
982 let pack = pack_package_impl(Some(tmp.path()), None, true).unwrap();
983 assert!(pack.files.contains(&"harn.toml".to_string()));
984 assert!(pack.files.contains(&"lib/main.harn".to_string()));
985 }
986}