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}