Skip to main content

harn_stdlib/
lib.rs

1//! Canonical embedded Harn standard library source catalog.
2//!
3//! This crate intentionally contains only static source strings. The compiler,
4//! runtime, and tooling read them through `harn-stdlib-catalog` and never
5//! depend on this crate, so editing a stdlib module rebuilds only this crate
6//! and the binaries that link it. A host calls [`install`] once at startup.
7//! Loading this catalog is pure: modules cannot register ambient
8//! effects, and every effectful stdlib function must receive its typed Harness
9//! capability explicitly from the caller, including callbacks that may perform
10//! effects.
11//!
12//! The sources are embedded as one zstd archive, about a fifth of their size,
13//! which the first catalog read decodes for the rest of the process.
14
15use std::sync::LazyLock;
16
17pub use harn_stdlib_catalog::{
18    builtin_reexports, StdlibCatalog, StdlibCliScript, StdlibPromptAsset, StdlibSource,
19};
20
21macro_rules! catalog_entries {
22    (
23        sources: [$($module:literal => $_module_path:literal),* $(,)?],
24        prompt_assets: [$($asset:literal => $_asset_path:literal),* $(,)?],
25        cli_scripts: [$($script:literal => $_script_path:literal),* $(,)?] $(,)?
26    ) => {
27        const MODULES: &[&str] = &[$($module),*];
28        const PROMPT_ASSETS: &[&str] = &[$($asset),*];
29        const CLI_SCRIPTS: &[&str] = &[$($script),*];
30    };
31}
32
33include!("catalog_entries.rs");
34include!(concat!(env!("OUT_DIR"), "/stdlib_archive.rs"));
35
36/// Decode the archive and cut it into the catalog's entries, in the order
37/// `build.rs` wrote them. The text lives for the rest of the process.
38fn unpack() -> StdlibCatalog {
39    let bytes = zstd::bulk::decompress(ARCHIVE, ARCHIVE_TEXT_LEN)
40        .unwrap_or_else(|error| panic!("the embedded stdlib archive does not decode: {error}"));
41    let mut rest: &'static str = String::from_utf8(bytes)
42        .expect("build.rs packed UTF-8 text")
43        .leak();
44    let mut lengths = LENGTHS.iter();
45    let mut next = || {
46        let (source, tail) = rest.split_at(*lengths.next().expect("one length per entry"));
47        rest = tail;
48        source
49    };
50    let sources = MODULES
51        .iter()
52        .map(|&module| StdlibSource {
53            module,
54            source: next(),
55        })
56        .collect::<Vec<_>>();
57    let prompt_assets = PROMPT_ASSETS
58        .iter()
59        .map(|&path| StdlibPromptAsset {
60            path,
61            source: next(),
62        })
63        .collect::<Vec<_>>();
64    let cli_scripts = CLI_SCRIPTS
65        .iter()
66        .map(|&name| StdlibCliScript {
67            name,
68            source: next(),
69        })
70        .collect::<Vec<_>>();
71    assert!(
72        rest.is_empty() && lengths.next().is_none(),
73        "the embedded stdlib archive does not match its catalog"
74    );
75    StdlibCatalog {
76        sources: sources.leak(),
77        prompt_assets: prompt_assets.leak(),
78        cli_scripts: cli_scripts.leak(),
79    }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct StdlibPublicFunction {
84    pub name: String,
85    pub signature: String,
86    pub required_params: usize,
87    pub total_params: usize,
88    pub variadic: bool,
89    pub doc: Option<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct StdlibEntrypointModule {
94    pub import_path: String,
95    pub category: String,
96}
97
98/// Canonical normalized connector event schemas, authored as Harn `type`
99/// declarations. This is the SOURCE OF TRUTH for the Rust event-payload
100/// structs in `crates/harn-vm/src/triggers/event/schemas_generated.rs`, which
101/// are generated from these declarations by `harn connector-schema-codegen`.
102///
103/// It is intentionally NOT registered in [`CATALOG`]: it is a codegen
104/// input (like `crates/harn-serve/openapi.yaml`), not a module loaded into every
105/// program.
106/// Exposing it as an embedded string keeps the generator independent of the
107/// current working directory.
108pub const CONNECTOR_EVENT_SCHEMAS_SOURCE: &str = include_str!("stdlib/stdlib_event_schemas.harn");
109
110/// This crate's catalog: every embedded stdlib module, prompt asset, and CLI
111/// script, in the order `catalog_entries.rs` lists them.
112pub static CATALOG: LazyLock<StdlibCatalog> = LazyLock::new(unpack);
113
114/// Install [`CATALOG`] as the process's standard library. Hosts call this once
115/// at startup, before running Harn code; calling it again is a no-op.
116///
117/// # Errors
118///
119/// Refuses when a different catalog is already installed.
120pub fn install() -> Result<(), harn_stdlib_catalog::CatalogAlreadyInstalled> {
121    harn_stdlib_catalog::install(&CATALOG)
122}
123
124pub fn get_stdlib_source(module: &str) -> Option<&'static str> {
125    CATALOG.source(module)
126}
127
128/// Find an embedded CLI subcommand script by name. Returns the embedded
129/// source string when present, or `None` if no script with that name is
130/// registered in [`CATALOG`].
131pub fn find_cli_script(name: &str) -> Option<&'static str> {
132    CATALOG.cli_script(name)
133}
134
135pub fn get_stdlib_prompt_asset(path: &str) -> Option<&'static str> {
136    CATALOG.prompt_asset(path)
137}
138
139pub fn public_functions_for_module(module: &str) -> Vec<StdlibPublicFunction> {
140    let Some(source) = get_stdlib_source(module) else {
141        return Vec::new();
142    };
143    public_functions_from_source(source)
144}
145
146pub fn entrypoint_modules() -> Vec<StdlibEntrypointModule> {
147    CATALOG
148        .sources
149        .iter()
150        .filter_map(|entry| {
151            entrypoint_category_from_source(entry.source).map(|category| StdlibEntrypointModule {
152                import_path: format!("std/{}", entry.module),
153                category,
154            })
155        })
156        .collect()
157}
158
159fn entrypoint_category_from_source(source: &str) -> Option<String> {
160    for line in source.lines() {
161        let line = line.trim();
162        if line.is_empty() {
163            continue;
164        }
165        if let Some(category) = line.strip_prefix("// @harn-entrypoint-category ") {
166            let category = category.trim();
167            return (!category.is_empty()).then(|| category.to_string());
168        }
169        if !line.starts_with("//") {
170            return None;
171        }
172    }
173    None
174}
175
176fn public_functions_from_source(source: &str) -> Vec<StdlibPublicFunction> {
177    let mut out = Vec::new();
178    let mut doc: Option<String> = None;
179    let lines = source.lines().collect::<Vec<_>>();
180    let mut index = 0usize;
181    while index < lines.len() {
182        let line = lines[index].trim();
183        if line.starts_with("/**") {
184            let (parsed, next) = parse_harndoc(&lines, index);
185            doc = parsed;
186            index = next;
187            continue;
188        }
189        if line.starts_with("pub fn ") {
190            let (signature_line, next) = collect_public_function_signature(&lines, index);
191            if let Some(function) = parse_public_function_line(&signature_line, doc.take()) {
192                out.push(function);
193                index = next;
194                continue;
195            }
196        }
197        if let Some(function) = parse_public_function_line(line, doc.take()) {
198            out.push(function);
199        } else if !line.is_empty() && !line.starts_with("//") {
200            doc = None;
201        }
202        index += 1;
203    }
204    out
205}
206
207fn collect_public_function_signature(lines: &[&str], start: usize) -> (String, usize) {
208    let mut parts = Vec::new();
209    let mut index = start;
210    while index < lines.len() {
211        parts.push(lines[index].trim().to_string());
212        let candidate = parts.join(" ");
213        if public_function_signature_complete(&candidate) {
214            return (candidate, index + 1);
215        }
216        index += 1;
217    }
218    (parts.join(" "), index)
219}
220
221fn public_function_signature_complete(line: &str) -> bool {
222    let Some(rest) = line.strip_prefix("pub fn ") else {
223        return false;
224    };
225    let Some((_, after_paren)) = rest.split_once('(') else {
226        return false;
227    };
228    matching_paren_len(after_paren).is_some()
229}
230
231fn parse_harndoc(lines: &[&str], start: usize) -> (Option<String>, usize) {
232    let mut parts = Vec::new();
233    let mut index = start;
234    while index < lines.len() {
235        let mut line = lines[index].trim();
236        if index == start {
237            line = line.trim_start_matches("/**").trim();
238        }
239        let done = line.ends_with("*/");
240        line = line.trim_end_matches("*/").trim();
241        line = line.trim_start_matches('*').trim();
242        if !line.is_empty() {
243            parts.push(line.to_string());
244        }
245        index += 1;
246        if done {
247            break;
248        }
249    }
250    let text = parts.join("\n").trim().to_string();
251    ((!text.is_empty()).then_some(text), index)
252}
253
254#[expect(
255    clippy::string_slice,
256    reason = "bounds come from str::find and char_indices"
257)]
258fn parse_public_function_line(line: &str, doc: Option<String>) -> Option<StdlibPublicFunction> {
259    let rest = line.strip_prefix("pub fn ")?.trim();
260    let name_end = rest.find('(')?;
261    let declaration_name = rest[..name_end].trim();
262    let name = declaration_name
263        .split_once('<')
264        .map_or(declaration_name, |(name, _)| name.trim());
265    if name.is_empty() {
266        return None;
267    }
268    let params_start = name_end + 1;
269    let params_len = matching_paren_len(&rest[params_start..])?;
270    let params = &rest[params_start..params_start + params_len];
271    let after = rest[params_start + params_len + 1..].trim();
272    let return_type = after
273        .strip_prefix("->")
274        .and_then(|tail| tail.split('{').next())
275        .map(str::trim)
276        .filter(|value| !value.is_empty());
277    let signature = match return_type {
278        Some(ret) => format!("{declaration_name}({params}) -> {ret}"),
279        None => format!("{declaration_name}({params})"),
280    };
281    let param_parts = split_top_level_params(params);
282    let total_params = param_parts
283        .iter()
284        .filter(|param| !param.trim().is_empty())
285        .count();
286    let variadic = param_parts
287        .iter()
288        .any(|param| param.trim_start().starts_with("..."));
289    let required_params = param_parts
290        .iter()
291        .filter(|param| {
292            let param = param.trim();
293            !param.is_empty() && !param.contains('=') && !param.starts_with("...")
294        })
295        .count();
296    Some(StdlibPublicFunction {
297        name: name.to_string(),
298        signature,
299        required_params,
300        total_params,
301        variadic,
302        doc,
303    })
304}
305
306fn matching_paren_len(input: &str) -> Option<usize> {
307    let mut depth = 1usize;
308    for (offset, ch) in input.char_indices() {
309        match ch {
310            '(' | '[' | '{' => depth += 1,
311            ')' | ']' | '}' => {
312                depth = depth.saturating_sub(1);
313                if depth == 0 {
314                    return Some(offset);
315                }
316            }
317            _ => {}
318        }
319    }
320    None
321}
322
323#[expect(
324    clippy::string_slice,
325    reason = "offsets come from char_indices and 1-byte ','"
326)]
327fn split_top_level_params(params: &str) -> Vec<&str> {
328    let mut out = Vec::new();
329    let mut depth = 0isize;
330    let mut start = 0usize;
331    for (offset, ch) in params.char_indices() {
332        match ch {
333            '(' | '[' | '{' => depth += 1,
334            ')' | ']' | '}' => depth -= 1,
335            ',' if depth == 0 => {
336                out.push(&params[start..offset]);
337                start = offset + 1;
338            }
339            _ => {}
340        }
341    }
342    out.push(&params[start..]);
343    out
344}
345
346#[cfg(test)]
347mod tests {
348    use std::collections::BTreeSet;
349
350    use super::{
351        entrypoint_modules, get_stdlib_prompt_asset, matching_paren_len,
352        parse_public_function_line, public_functions_for_module, CATALOG,
353    };
354
355    #[test]
356    fn stdlib_sources_are_non_empty() {
357        for entry in CATALOG.sources {
358            assert!(
359                !entry.source.trim().is_empty(),
360                "{} should have non-empty source",
361                entry.module
362            );
363        }
364    }
365
366    #[test]
367    fn cli_scripts_are_non_empty_and_uniquely_named() {
368        let mut seen = BTreeSet::new();
369        for entry in CATALOG.cli_scripts {
370            assert!(
371                !entry.source.trim().is_empty(),
372                "cli/{} should have non-empty source",
373                entry.name
374            );
375            assert!(
376                seen.insert(entry.name),
377                "cli/{} is registered more than once in CATALOG.cli_scripts",
378                entry.name
379            );
380        }
381    }
382
383    #[test]
384    fn stdlib_source_names_are_unique() {
385        let mut names = BTreeSet::new();
386        for entry in CATALOG.sources {
387            assert!(names.insert(entry.module), "duplicate {}", entry.module);
388        }
389    }
390
391    #[test]
392    fn stdlib_prompt_assets_are_non_empty() {
393        for entry in CATALOG.prompt_assets {
394            assert!(
395                !entry.source.trim().is_empty(),
396                "{} should have non-empty prompt asset source",
397                entry.path
398            );
399        }
400    }
401
402    #[test]
403    fn stdlib_prompt_asset_paths_are_unique() {
404        let mut paths = BTreeSet::new();
405        for entry in CATALOG.prompt_assets {
406            assert!(paths.insert(entry.path), "duplicate {}", entry.path);
407        }
408    }
409
410    #[test]
411    fn key_stdlib_prompt_assets_resolve() {
412        for path in [
413            "std/agent/prompts/tool_contract_text.harn.prompt",
414            "std/agent/prompts/default_nudge.harn.prompt",
415            "std/agent/prompts/completion_judge_default.harn.prompt",
416            "std/llm/prompts/directive_envelope_instructions.harn.prompt",
417            "std/llm/prompts/directive_nonce_authority.harn.prompt",
418            "std/workflow/prompts/stage.harn.prompt",
419            "std/orchestration/prompts/compaction_summary.harn.prompt",
420            "std/orchestration/prompts/compaction_policy_extension.harn.prompt",
421            "std/orchestration/prompts/compaction_policy_replacement.harn.prompt",
422            "std/orchestration/prompts/compaction_state_grounding.harn.prompt",
423        ] {
424            assert!(
425                get_stdlib_prompt_asset(path).is_some(),
426                "{path} should resolve"
427            );
428        }
429    }
430
431    #[test]
432    fn public_function_catalog_derives_signatures_from_harn_source() {
433        let exports = public_functions_for_module("workflow/execute");
434        assert_eq!(exports.len(), 1);
435        assert_eq!(exports[0].name, "workflow_execute");
436        assert_eq!(
437            exports[0].signature,
438            "workflow_execute( harness: Harness, task: string, graph: dict, artifacts: list<unknown>? = nil, options: dict? = nil, )"
439        );
440        assert_eq!(exports[0].required_params, 3);
441        assert_eq!(exports[0].total_params, 5);
442    }
443
444    #[test]
445    fn tool_registry_from_catalog_includes_cli_metadata_parameter() {
446        let function = public_functions_for_module("tools")
447            .into_iter()
448            .find(|function| function.name == "tool_registry_from")
449            .expect("std/tools should export tool_registry_from");
450        assert_eq!(function.required_params, 1);
451        assert_eq!(function.total_params, 2);
452        assert!(function
453            .signature
454            .contains("options: ToolRegistryOptions? = nil"));
455    }
456
457    #[test]
458    fn soft_landing_modules_own_their_public_surface() {
459        // The cut-rule layer is three owners: the meter registry, the
460        // predicate evaluator, and the latched landing machine. A rename that
461        // drops one of these from its module is the failure this catches.
462        for (module, expected) in [
463            (
464                "agent/run_meter",
465                &[
466                    "meter_observation_lower",
467                    "meter_observation_render",
468                    "meter_observation_upper",
469                    "run_meter_bounded",
470                    "run_meter_charge",
471                    "run_meter_digest",
472                    "run_meter_exact",
473                    "run_meter_field_registered",
474                    "run_meter_field_spec",
475                    "run_meter_fields",
476                    "run_meter_new",
477                    "run_meter_observe",
478                    "run_meter_project_next_call",
479                    "run_meter_read",
480                    "run_meter_registry_digest",
481                    "run_meter_unavailable",
482                ][..],
483            ),
484            (
485                "agent/cut_landing",
486                &[
487                    "cut_action_allowed",
488                    "cut_predicate_evaluate",
489                    "cut_predicate_render",
490                    "cut_predicate_validate",
491                    "cut_rules_digest",
492                    "cut_rules_tick",
493                    "cut_rules_validate",
494                    "cut_state_complete",
495                    "cut_state_new",
496                    "cut_state_record_admission",
497                    "cut_state_record_terminal_emit",
498                    "cut_terminal_emit_due",
499                    "cut_terminal_evidence",
500                ][..],
501            ),
502        ] {
503            let exports = public_functions_for_module(module)
504                .into_iter()
505                .map(|function| function.name)
506                .collect::<BTreeSet<_>>();
507            for name in expected {
508                assert!(exports.contains(*name), "std/{module} should export {name}");
509            }
510        }
511    }
512
513    #[test]
514    fn pace_cut_rule_module_owns_public_functions() {
515        let cut_rule_exports = public_functions_for_module("agent/cut_rules")
516            .into_iter()
517            .map(|function| function.name)
518            .collect::<BTreeSet<_>>();
519        for name in [
520            "pace_cut_rule_action_of",
521            "pace_cut_rule_check_max_injections",
522            "pace_cut_rule_decision",
523            "pace_cut_rule_extend_max",
524        ] {
525            assert!(
526                cut_rule_exports.contains(name),
527                "std/agent/cut_rules should export {name}"
528            );
529        }
530
531        let governor_exports = public_functions_for_module("agent/governors")
532            .into_iter()
533            .map(|function| function.name)
534            .collect::<BTreeSet<_>>();
535        for removed in [
536            "governor_pace_check_max_injections",
537            "governor_pace_decision",
538            "governor_pace_extend_max",
539            "pace_action_of",
540        ] {
541            assert!(
542                !governor_exports.contains(removed),
543                "std/agent/governors must not retain removed export {removed}"
544            );
545        }
546    }
547
548    #[test]
549    fn command_stdlib_module_exports_step_helpers() {
550        let exports = public_functions_for_module("command")
551            .into_iter()
552            .map(|function| function.name)
553            .collect::<BTreeSet<_>>();
554        for name in [
555            "command_run",
556            "command_wait",
557            "command_wait_for_output",
558            "command_cancel",
559            "command_run_streaming",
560            "command_output_tail",
561            "command_json",
562            "command_json_step",
563            "command_try",
564            "command_step",
565            "command_steps_append",
566            "command_last_failed_step",
567            "command_step_ref",
568            "command_output_text",
569            "argv_label",
570            "shell_command_from_argv",
571            "shell_command_from_value",
572        ] {
573            assert!(exports.contains(name), "std/command should export {name}");
574        }
575    }
576
577    #[test]
578    fn disclosure_stdlib_module_exports_render_helpers() {
579        let exports = public_functions_for_module("disclosure")
580            .into_iter()
581            .map(|function| function.name)
582            .collect::<BTreeSet<_>>();
583        assert!(
584            exports.contains("render"),
585            "std/disclosure should export render"
586        );
587        assert!(
588            exports.contains("git_trailers"),
589            "std/disclosure should export git_trailers"
590        );
591        assert!(
592            exports.contains("slack_message_disclosure"),
593            "std/disclosure should export slack_message_disclosure"
594        );
595        assert!(
596            exports.contains("append_git_trailers"),
597            "std/disclosure should export append_git_trailers"
598        );
599    }
600
601    #[test]
602    fn async_stdlib_exports_predicate_backoff_name_only() {
603        let exports = public_functions_for_module("async")
604            .into_iter()
605            .map(|function| function.name)
606            .collect::<BTreeSet<_>>();
607        assert!(
608            exports.contains("retry_predicate_with_backoff"),
609            "std/async should export retry_predicate_with_backoff"
610        );
611        assert!(
612            !exports.contains("retry_with_backoff"),
613            "std/async should not retain the old retry_with_backoff export"
614        );
615    }
616
617    #[test]
618    fn signal_stdlib_module_exports_interrupt_helpers() {
619        let exports = public_functions_for_module("signal")
620            .into_iter()
621            .map(|function| function.name)
622            .collect::<BTreeSet<_>>();
623        for name in [
624            "on_interrupt",
625            "off_interrupt",
626            "interrupted",
627            "with_interrupt",
628        ] {
629            assert!(exports.contains(name), "std/signal should export {name}");
630        }
631    }
632
633    #[test]
634    fn git_stdlib_module_exports_local_wrappers() {
635        let exports = public_functions_for_module("git")
636            .into_iter()
637            .map(|function| function.name)
638            .collect::<BTreeSet<_>>();
639        for name in [
640            "git_run",
641            "git_status",
642            "git_current_branch",
643            "git_log",
644            "git_switch",
645            "git_pull_ff_only",
646            "git_find_tool",
647            "git_run_tool",
648            "git_tools",
649            "git_toolbox_tools",
650        ] {
651            assert!(exports.contains(name), "std/git should export {name}");
652        }
653    }
654
655    #[test]
656    fn agent_workers_exports_suspend_resume_wrappers() {
657        let exports = public_functions_for_module("agent/workers");
658        let suspend = exports
659            .iter()
660            .find(|function| function.name == "suspend_agent")
661            .expect("std/agent/workers should export suspend_agent");
662        assert_eq!(
663            suspend.signature,
664            "suspend_agent( agents: HarnessAgent, worker: unknown, reason: string = \"\", options: dict? = nil, ) -> unknown"
665        );
666        assert_eq!(suspend.required_params, 2);
667        assert_eq!(suspend.total_params, 4);
668
669        let resume = exports
670            .iter()
671            .find(|function| function.name == "resume_agent")
672            .expect("std/agent/workers should export resume_agent");
673        assert_eq!(
674            resume.signature,
675            "resume_agent( agents: HarnessAgent, worker_or_snapshot: unknown, resume_input: unknown = nil, continue_transcript: bool = true, ) -> unknown"
676        );
677        assert_eq!(resume.required_params, 2);
678        assert_eq!(resume.total_params, 4);
679
680        let stop = exports
681            .iter()
682            .find(|function| function.name == "agent_stop")
683            .expect("std/agent/workers should export agent_stop");
684        assert_eq!(
685            stop.signature,
686            "agent_stop( agents: HarnessAgent, worker: unknown, options: AgentStopOptions? = nil, ) -> unknown"
687        );
688        assert_eq!(stop.required_params, 2);
689        assert_eq!(stop.total_params, 3);
690
691        let parse_resume = exports
692            .iter()
693            .find(|function| function.name == "parse_resume_conditions")
694            .expect("std/agent/workers should export parse_resume_conditions");
695        assert_eq!(
696            parse_resume.signature,
697            "parse_resume_conditions( agents: HarnessAgent, conditions: ResumeConditions? = nil, ) -> ResumeConditions?"
698        );
699        assert_eq!(parse_resume.required_params, 1);
700        assert_eq!(parse_resume.total_params, 2);
701
702        let lifecycle = exports
703            .iter()
704            .find(|function| function.name == "agent_lifecycle_tools")
705            .expect("std/agent/workers should export agent_lifecycle_tools");
706        assert_eq!(
707            lifecycle.signature,
708            "agent_lifecycle_tools( agents: HarnessAgent, registry: ToolRegistry? = nil, options: AgentLifecycleToolsOptions? = nil, ) -> ToolRegistry"
709        );
710        assert_eq!(lifecycle.required_params, 1);
711        assert_eq!(lifecycle.total_params, 3);
712    }
713
714    #[test]
715    fn tui_stdlib_module_exports_terminal_helpers() {
716        let exports = public_functions_for_module("tui")
717            .into_iter()
718            .map(|function| function.name)
719            .collect::<BTreeSet<_>>();
720        for name in ["page", "terminal_width", "rule", "clear", "select_from"] {
721            assert!(exports.contains(name), "std/tui should export {name}");
722        }
723    }
724
725    #[test]
726    fn semver_stdlib_module_exports_release_helpers() {
727        let exports = public_functions_for_module("semver")
728            .into_iter()
729            .map(|function| function.name)
730            .collect::<BTreeSet<_>>();
731        for name in [
732            "strip_v",
733            "add_v",
734            "is_v_semver",
735            "is_v_release_semver",
736            "parse",
737            "parse_release",
738            "is_prerelease_identifier",
739            "is_prerelease",
740            "compare_release",
741            "max_canonical_tag",
742            "next",
743            "bump_type",
744            "version_from_release_branch",
745            "version_from_tag",
746        ] {
747            assert!(exports.contains(name), "std/semver should export {name}");
748        }
749    }
750
751    #[test]
752    fn changelog_stdlib_module_exports_typed_primitives() {
753        let exports = public_functions_for_module("changelog")
754            .into_iter()
755            .map(|function| function.name)
756            .collect::<BTreeSet<_>>();
757        for name in [
758            "changelog_validate_categories",
759            "changelog_parse_fragment",
760            "changelog_order_fragments",
761            "changelog_normalize_fragment_body",
762            "changelog_assemble_fragments",
763            "changelog_parse_sections",
764            "changelog_find_section",
765            "changelog_merge_unreleased",
766        ] {
767            assert!(exports.contains(name), "std/changelog should export {name}");
768        }
769    }
770
771    #[test]
772    fn text_stdlib_module_exports_regex_and_pad_helpers() {
773        let exports = public_functions_for_module("text")
774            .into_iter()
775            .map(|function| function.name)
776            .collect::<BTreeSet<_>>();
777        for name in [
778            "pad_left",
779            "pad_right",
780            "repeat_string",
781            "regex_first_capture",
782            "regex_capture_groups",
783            "regex_all_first_captures",
784        ] {
785            assert!(exports.contains(name), "std/text should export {name}");
786        }
787    }
788
789    #[test]
790    fn harn_entrypoint_catalog_is_declared_by_stdlib_sources() {
791        let modules = entrypoint_modules();
792        let entries = modules
793            .iter()
794            .map(|module| (module.import_path.as_str(), module.category.as_str()))
795            .collect::<BTreeSet<_>>();
796        for entry in [
797            ("std/agent/loop", "agent.stdlib"),
798            ("std/agent/primitives", "agent.stdlib"),
799            ("std/workflow/execute", "workflow.stdlib"),
800        ] {
801            assert!(entries.contains(&entry), "{entry:?} should be declared");
802        }
803    }
804
805    #[test]
806    fn matching_paren_len_closes_on_every_bracket_kind() {
807        // Each closer kind must terminate the scan once depth returns to zero,
808        // mirroring the symmetric opener arm and `split_top_level_params`.
809        assert_eq!(matching_paren_len("a, b)"), Some(4));
810        assert_eq!(matching_paren_len("x: [int])"), Some(8));
811        assert_eq!(matching_paren_len("x: {a: int})"), Some(11));
812        // A top-level `]`/`}` is the matching close for the consumed opener and
813        // must return rather than scanning to the end and yielding `None`.
814        assert_eq!(matching_paren_len("x]"), Some(1));
815        assert_eq!(matching_paren_len("x}"), Some(1));
816        assert_eq!(matching_paren_len("unterminated"), None);
817    }
818
819    #[test]
820    fn parse_public_function_line_handles_record_typed_params() {
821        let parsed =
822            parse_public_function_line("pub fn configure(opts: {retries: int}) -> bool", None)
823                .expect("signature with a record-typed parameter should parse");
824        assert_eq!(parsed.name, "configure");
825        assert_eq!(parsed.signature, "configure(opts: {retries: int}) -> bool");
826        assert_eq!(parsed.total_params, 1);
827        assert_eq!(parsed.required_params, 1);
828    }
829
830    #[test]
831    fn parse_public_function_line_indexes_generic_functions_by_base_name() {
832        let parsed = parse_public_function_line(
833            "pub fn map<T, U>(items: list<T>, project: fn(T) -> U) -> list<U>",
834            None,
835        )
836        .expect("generic public function should parse");
837        assert_eq!(parsed.name, "map");
838        assert_eq!(
839            parsed.signature,
840            "map<T, U>(items: list<T>, project: fn(T) -> U) -> list<U>"
841        );
842        assert_eq!(parsed.total_params, 2);
843        assert_eq!(parsed.required_params, 2);
844    }
845}