Skip to main content

harn_cli/package/
manifest.rs

1use super::errors::PackageError;
2use super::*;
3pub use harn_modules::personas::{
4    PersonaAutonomyTier, PersonaManifestEntry, PersonaValidationError, ResolvedPersonaManifest,
5};
6
7#[derive(Debug, Clone, Deserialize)]
8pub struct Manifest {
9    pub package: Option<PackageInfo>,
10    #[serde(default)]
11    pub dependencies: HashMap<String, Dependency>,
12    #[serde(default)]
13    pub mcp: Vec<McpServerConfig>,
14    #[serde(default)]
15    pub check: CheckConfig,
16    #[serde(default)]
17    pub workspace: WorkspaceConfig,
18    /// `[registry]` table — lightweight package discovery index
19    /// configuration. The CLI also honors `HARN_PACKAGE_REGISTRY` and
20    /// `--registry` flags for one-off overrides.
21    #[serde(default)]
22    pub registry: PackageRegistryConfig,
23    /// `[skills]` table — per-project skill discovery configuration
24    /// (paths, lookup_order, disable).
25    #[serde(default)]
26    pub skills: SkillsConfig,
27    /// `[[skill.source]]` array-of-tables — declared skill sources
28    /// (filesystem, git, reserved registry).
29    #[serde(default)]
30    pub skill: SkillTables,
31    /// `[capabilities]` section — per-provider-per-model override of
32    /// the shipped capability matrix (`defer_loading`, `tool_search`,
33    /// `prompt_caching`, etc.). Entries under `[[capabilities.provider.<name>]]`
34    /// are prepended to the built-in rules for the same provider so
35    /// early adopters can flag proxied endpoints as supporting tool
36    /// search without waiting for a Harn release. See
37    /// `harn_vm::llm::capabilities` for the rule schema.
38    #[serde(default)]
39    pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
40    /// Stable exported package modules. Keys are the logical import
41    /// suffixes (e.g. `providers/openai`) and values are package-root-
42    /// relative file paths. Consumers import them via `<package>/<key>`.
43    #[serde(default)]
44    pub exports: HashMap<String, String>,
45    /// `[llm]` section — packaged provider definitions, aliases,
46    /// inference rules, tier rules, and model defaults. Uses the same
47    /// schema as `providers.toml`, but merges into the current run
48    /// instead of replacing the global config file.
49    #[serde(default)]
50    pub llm: harn_vm::llm_config::ProvidersConfig,
51    /// `[[hooks]]` array-of-tables — declarative runtime hooks installed
52    /// once per process/thread before execution starts. Matches the
53    /// manifest-extension ABI shape added by `[exports]` / `[llm]`, but
54    /// the handlers themselves live in Harn modules.
55    #[serde(default)]
56    pub hooks: Vec<HookConfig>,
57    /// `[[triggers]]` array-of-tables — declarative event-driven trigger
58    /// registrations that resolve local handlers and predicates from Harn
59    /// modules at load time and preserve remote URI schemes for later
60    /// dispatcher work.
61    #[serde(default)]
62    pub triggers: Vec<TriggerManifestEntry>,
63    /// `[[handoff_routes]]` array-of-tables — declarative handoff route data.
64    /// Route selection stays in Harn stdlib/persona code; the Rust manifest
65    /// loader makes these tenant routes available to that code.
66    #[serde(default)]
67    pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
68    /// `[[providers]]` array-of-tables — provider-specific connector
69    /// overrides used by the orchestrator to load either builtin Rust
70    /// connectors or `.harn` modules as connector implementations.
71    #[serde(default)]
72    pub providers: Vec<ProviderManifestEntry>,
73    /// `[[personas]]` array-of-tables — durable, non-executing agent role
74    /// manifests. Personas bind an entry workflow to tools, capabilities,
75    /// autonomy, budgets, receipts, handoffs, evals, and rollout metadata.
76    #[serde(default)]
77    pub personas: Vec<PersonaManifestEntry>,
78    /// `[connector_contract]` table — deterministic package-local fixtures
79    /// consumed by `harn connector check` for pure-Harn connector packages.
80    #[serde(default, alias = "connector-contract")]
81    pub connector_contract: ConnectorContractConfig,
82    /// `[orchestrator]` table — listener-level controls shared by
83    /// manifest-driven ingress surfaces.
84    #[serde(default)]
85    pub orchestrator: OrchestratorConfig,
86}
87
88#[derive(Debug, Clone, Default, Deserialize)]
89pub struct OrchestratorConfig {
90    #[serde(default, alias = "allowed-origins")]
91    pub allowed_origins: Vec<String>,
92    #[serde(default, alias = "max-body-bytes")]
93    pub max_body_bytes: Option<usize>,
94    #[serde(default)]
95    pub budget: OrchestratorBudgetSpec,
96    #[serde(default)]
97    pub drain: OrchestratorDrainConfig,
98    #[serde(default)]
99    pub pumps: OrchestratorPumpConfig,
100}
101
102#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
103pub struct OrchestratorBudgetSpec {
104    #[serde(default)]
105    pub daily_cost_usd: Option<f64>,
106    #[serde(default)]
107    pub hourly_cost_usd: Option<f64>,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111pub struct OrchestratorDrainConfig {
112    #[serde(default = "default_orchestrator_drain_max_items", alias = "max-items")]
113    pub max_items: usize,
114    #[serde(
115        default = "default_orchestrator_drain_deadline_seconds",
116        alias = "deadline-seconds"
117    )]
118    pub deadline_seconds: u64,
119}
120
121impl Default for OrchestratorDrainConfig {
122    fn default() -> Self {
123        Self {
124            max_items: default_orchestrator_drain_max_items(),
125            deadline_seconds: default_orchestrator_drain_deadline_seconds(),
126        }
127    }
128}
129
130pub(crate) fn default_orchestrator_drain_max_items() -> usize {
131    1024
132}
133
134pub(crate) fn default_orchestrator_drain_deadline_seconds() -> u64 {
135    30
136}
137
138#[derive(Debug, Clone, Deserialize)]
139pub struct OrchestratorPumpConfig {
140    #[serde(
141        default = "default_orchestrator_pump_max_outstanding",
142        alias = "max-outstanding"
143    )]
144    pub max_outstanding: usize,
145}
146
147impl Default for OrchestratorPumpConfig {
148    fn default() -> Self {
149        Self {
150            max_outstanding: default_orchestrator_pump_max_outstanding(),
151        }
152    }
153}
154
155pub(crate) fn default_orchestrator_pump_max_outstanding() -> usize {
156    64
157}
158
159#[derive(Debug, Clone, Deserialize)]
160pub struct HookConfig {
161    pub event: harn_vm::orchestration::HookEvent,
162    #[serde(default = "default_hook_pattern")]
163    pub pattern: String,
164    pub handler: String,
165}
166
167pub(crate) fn default_hook_pattern() -> String {
168    "*".to_string()
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct TriggerManifestEntry {
173    pub id: String,
174    #[serde(default)]
175    pub kind: Option<TriggerKind>,
176    #[serde(default)]
177    pub provider: Option<harn_vm::ProviderId>,
178    #[serde(default, alias = "tier")]
179    pub autonomy_tier: harn_vm::AutonomyTier,
180    #[serde(default, rename = "match")]
181    pub match_: Option<TriggerMatchExpr>,
182    #[serde(default)]
183    pub sources: Vec<TriggerSourceManifestEntry>,
184    #[serde(default)]
185    pub when: Option<String>,
186    #[serde(default)]
187    pub when_budget: Option<TriggerWhenBudgetSpec>,
188    pub handler: String,
189    #[serde(default)]
190    pub dedupe_key: Option<String>,
191    #[serde(default)]
192    pub retry: TriggerRetrySpec,
193    #[serde(default)]
194    pub priority: Option<TriggerPriorityField>,
195    #[serde(default)]
196    pub budget: TriggerBudgetSpec,
197    #[serde(default)]
198    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
199    #[serde(default)]
200    pub throttle: Option<TriggerThrottleManifestSpec>,
201    #[serde(default)]
202    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
203    #[serde(default)]
204    pub debounce: Option<TriggerDebounceManifestSpec>,
205    #[serde(default)]
206    pub singleton: Option<TriggerSingletonManifestSpec>,
207    #[serde(default)]
208    pub batch: Option<TriggerBatchManifestSpec>,
209    #[serde(default)]
210    pub window: Option<TriggerStreamWindowManifestSpec>,
211    #[serde(default, alias = "dlq-alerts")]
212    pub dlq_alerts: Vec<TriggerDlqAlertManifestSpec>,
213    #[serde(default)]
214    pub secrets: BTreeMap<String, String>,
215    #[serde(default)]
216    pub filter: Option<String>,
217    #[serde(flatten, default)]
218    pub kind_specific: BTreeMap<String, toml::Value>,
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct TriggerSourceManifestEntry {
223    #[serde(default)]
224    pub id: Option<String>,
225    pub kind: TriggerKind,
226    pub provider: harn_vm::ProviderId,
227    #[serde(default, rename = "match")]
228    pub match_: Option<TriggerMatchExpr>,
229    #[serde(default)]
230    pub dedupe_key: Option<String>,
231    #[serde(default)]
232    pub retry: Option<TriggerRetrySpec>,
233    #[serde(default)]
234    pub priority: Option<TriggerPriorityField>,
235    #[serde(default)]
236    pub budget: Option<TriggerBudgetSpec>,
237    #[serde(default)]
238    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
239    #[serde(default)]
240    pub throttle: Option<TriggerThrottleManifestSpec>,
241    #[serde(default)]
242    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
243    #[serde(default)]
244    pub debounce: Option<TriggerDebounceManifestSpec>,
245    #[serde(default)]
246    pub singleton: Option<TriggerSingletonManifestSpec>,
247    #[serde(default)]
248    pub batch: Option<TriggerBatchManifestSpec>,
249    #[serde(default)]
250    pub window: Option<TriggerStreamWindowManifestSpec>,
251    #[serde(default)]
252    pub secrets: BTreeMap<String, String>,
253    #[serde(default)]
254    pub filter: Option<String>,
255    #[serde(flatten, default)]
256    pub kind_specific: BTreeMap<String, toml::Value>,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "kebab-case")]
261pub enum TriggerKind {
262    Webhook,
263    Cron,
264    Poll,
265    Stream,
266    Predicate,
267    A2aPush,
268}
269
270#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
271pub struct TriggerMatchExpr {
272    #[serde(default)]
273    pub events: Vec<String>,
274    #[serde(flatten, default)]
275    pub extra: BTreeMap<String, toml::Value>,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct TriggerRetrySpec {
280    #[serde(default)]
281    pub max: u32,
282    #[serde(default)]
283    pub backoff: TriggerRetryBackoff,
284    #[serde(default = "default_trigger_retention_days")]
285    pub retention_days: u32,
286}
287
288#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "kebab-case")]
290pub enum TriggerRetryBackoff {
291    #[default]
292    Immediate,
293    Svix,
294}
295
296pub(crate) fn default_trigger_retention_days() -> u32 {
297    harn_vm::DEFAULT_INBOX_RETENTION_DAYS
298}
299
300impl Default for TriggerRetrySpec {
301    fn default() -> Self {
302        Self {
303            max: 0,
304            backoff: TriggerRetryBackoff::default(),
305            retention_days: default_trigger_retention_days(),
306        }
307    }
308}
309
310#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "lowercase")]
312pub enum TriggerDispatchPriority {
313    High,
314    #[default]
315    Normal,
316    Low,
317}
318
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
320#[serde(untagged)]
321pub enum TriggerPriorityField {
322    Dispatch(TriggerDispatchPriority),
323    Flow(TriggerPriorityManifestSpec),
324}
325
326#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
327pub struct TriggerBudgetSpec {
328    #[serde(default)]
329    pub max_cost_usd: Option<f64>,
330    #[serde(default, alias = "tokens_max")]
331    pub max_tokens: Option<u64>,
332    #[serde(default)]
333    pub daily_cost_usd: Option<f64>,
334    #[serde(default)]
335    pub hourly_cost_usd: Option<f64>,
336    #[serde(default)]
337    pub max_autonomous_decisions_per_hour: Option<u64>,
338    #[serde(default)]
339    pub max_autonomous_decisions_per_day: Option<u64>,
340    #[serde(default)]
341    pub max_concurrent: Option<u32>,
342    #[serde(default)]
343    pub on_budget_exhausted: harn_vm::TriggerBudgetExhaustionStrategy,
344}
345
346#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
347pub struct TriggerWhenBudgetSpec {
348    #[serde(default)]
349    pub max_cost_usd: Option<f64>,
350    #[serde(default)]
351    pub tokens_max: Option<u64>,
352    #[serde(default)]
353    pub timeout: Option<String>,
354}
355
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357pub struct TriggerConcurrencyManifestSpec {
358    #[serde(default)]
359    pub key: Option<String>,
360    pub max: u32,
361}
362
363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
364pub struct TriggerThrottleManifestSpec {
365    #[serde(default)]
366    pub key: Option<String>,
367    pub period: String,
368    pub max: u32,
369}
370
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372pub struct TriggerRateLimitManifestSpec {
373    #[serde(default)]
374    pub key: Option<String>,
375    pub period: String,
376    pub max: u32,
377}
378
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380pub struct TriggerDebounceManifestSpec {
381    pub key: String,
382    pub period: String,
383}
384
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386pub struct TriggerSingletonManifestSpec {
387    #[serde(default)]
388    pub key: Option<String>,
389}
390
391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
392pub struct TriggerBatchManifestSpec {
393    #[serde(default)]
394    pub key: Option<String>,
395    pub size: u32,
396    pub timeout: String,
397}
398
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400pub struct TriggerPriorityManifestSpec {
401    pub key: String,
402    #[serde(default)]
403    pub order: Vec<String>,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(rename_all = "kebab-case")]
408pub enum TriggerStreamWindowMode {
409    Tumbling,
410    Sliding,
411    Session,
412}
413
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
415pub struct TriggerStreamWindowManifestSpec {
416    pub mode: TriggerStreamWindowMode,
417    #[serde(default)]
418    pub key: Option<String>,
419    #[serde(default)]
420    pub size: Option<String>,
421    #[serde(default)]
422    pub every: Option<String>,
423    #[serde(default)]
424    pub gap: Option<String>,
425    #[serde(default)]
426    pub max_items: Option<u32>,
427}
428
429#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
430pub struct TriggerDlqAlertManifestSpec {
431    #[serde(default)]
432    pub destinations: Vec<TriggerDlqAlertDestination>,
433    #[serde(default)]
434    pub threshold: TriggerDlqAlertThreshold,
435}
436
437#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
438pub struct TriggerDlqAlertThreshold {
439    #[serde(default, alias = "entries-in-1h")]
440    pub entries_in_1h: Option<u32>,
441    #[serde(default, alias = "percent-of-dispatches")]
442    pub percent_of_dispatches: Option<f64>,
443}
444
445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
446#[serde(tag = "kind", rename_all = "snake_case")]
447pub enum TriggerDlqAlertDestination {
448    Slack {
449        channel: String,
450        #[serde(default)]
451        webhook_url_env: Option<String>,
452    },
453    Email {
454        address: String,
455    },
456    Webhook {
457        url: String,
458        #[serde(default)]
459        headers: BTreeMap<String, String>,
460    },
461}
462
463impl TriggerDlqAlertDestination {
464    pub fn label(&self) -> String {
465        match self {
466            Self::Slack { channel, .. } => format!("slack:{channel}"),
467            Self::Email { address } => format!("email:{address}"),
468            Self::Webhook { url, .. } => format!("webhook:{url}"),
469        }
470    }
471}
472
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub enum TriggerHandlerUri {
475    Local(TriggerFunctionRef),
476    A2a {
477        target: String,
478        allow_cleartext: bool,
479    },
480    Worker {
481        queue: String,
482    },
483    Persona {
484        name: String,
485    },
486}
487
488#[derive(Debug, Clone, PartialEq, Eq)]
489pub struct TriggerFunctionRef {
490    pub raw: String,
491    pub module_name: Option<String>,
492    pub function_name: String,
493}
494
495/// `[skills]` table body.
496#[derive(Debug, Default, Clone, Deserialize)]
497#[allow(dead_code)] // `defaults` is parsed per harn#73; default application remains staged.
498pub struct SkillsConfig {
499    /// Additional filesystem roots to scan. Each entry may be a
500    /// literal directory or a glob (`packages/*/skills`). Resolved
501    /// relative to the directory holding harn.toml.
502    #[serde(default)]
503    pub paths: Vec<String>,
504    /// Override priority order. Values are layer labels —
505    /// `cli`, `env`, `project`, `manifest`, `user`, `package`,
506    /// `system`, `host`. Unlisted layers fall through to default
507    /// priority after listed ones.
508    #[serde(default)]
509    pub lookup_order: Vec<String>,
510    /// Disable entire layers. Same label set as `lookup_order`.
511    #[serde(default)]
512    pub disable: Vec<String>,
513    /// Optional remote registry base URL used to resolve
514    /// `<fingerprint>.pub` when a signer is not installed locally.
515    #[serde(default)]
516    pub signer_registry_url: Option<String>,
517    /// `[skills.defaults]` inline sub-table — applied to every
518    /// discovered skill when the field is unset in its SKILL.md
519    /// frontmatter.
520    #[serde(default)]
521    pub defaults: SkillDefaults,
522}
523
524#[derive(Debug, Default, Clone, Deserialize)]
525#[allow(dead_code)] // Parsed per harn#73; loader default application is still staged.
526pub struct SkillDefaults {
527    #[serde(default)]
528    pub tool_search: Option<String>,
529    #[serde(default)]
530    pub always_loaded: Vec<String>,
531}
532
533/// Container for `[[skill.source]]` array-of-tables.
534#[derive(Debug, Default, Clone, Deserialize)]
535pub struct SkillTables {
536    #[serde(default, rename = "source")]
537    pub sources: Vec<SkillSourceEntry>,
538}
539
540/// One `[[skill.source]]` entry. The `registry` variant is accepted
541/// for forward-compat but inert — see issue #73 and `docs/src/skills.md`
542/// for the marketplace timeline.
543#[derive(Debug, Clone, Deserialize)]
544#[serde(tag = "type", rename_all = "lowercase")]
545#[allow(dead_code)] // Git/registry skill sources are manifest-reserved by harn#73.
546pub enum SkillSourceEntry {
547    Fs {
548        path: String,
549        #[serde(default)]
550        namespace: Option<String>,
551    },
552    Git {
553        url: String,
554        #[serde(default)]
555        tag: Option<String>,
556        #[serde(default)]
557        namespace: Option<String>,
558    },
559    Registry {
560        #[serde(default)]
561        url: Option<String>,
562        #[serde(default)]
563        name: Option<String>,
564    },
565}
566
567/// Severity override for preflight diagnostics. `error` (default) fails
568/// `harn check`; `warning` reports but does not fail; `off` suppresses
569/// entirely. Accepted via `[check].preflight_severity` in harn.toml so
570/// repos with hosts that do not expose every capability statically can
571/// keep the checker running on genuine type errors.
572#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
573pub enum PreflightSeverity {
574    #[default]
575    Error,
576    Warning,
577    Off,
578}
579
580impl PreflightSeverity {
581    pub fn from_opt(raw: Option<&str>) -> Self {
582        match raw.map(|s| s.to_ascii_lowercase()) {
583            Some(v) if v == "warning" || v == "warn" => Self::Warning,
584            Some(v) if v == "off" || v == "allow" || v == "silent" => Self::Off,
585            _ => Self::Error,
586        }
587    }
588}
589
590#[derive(Debug, Default, Clone, Deserialize)]
591pub struct CheckConfig {
592    #[serde(default)]
593    pub strict: bool,
594    #[serde(default)]
595    pub strict_types: bool,
596    #[serde(default)]
597    pub disable_rules: Vec<String>,
598    #[serde(default)]
599    pub host_capabilities: HashMap<String, Vec<String>>,
600    #[serde(default, alias = "host_capabilities_file")]
601    pub host_capabilities_path: Option<String>,
602    #[serde(default)]
603    pub bundle_root: Option<String>,
604    /// Downgrade or suppress preflight diagnostics. See
605    /// [`PreflightSeverity`].
606    #[serde(default, alias = "preflight-severity")]
607    pub preflight_severity: Option<String>,
608    /// List of `"capability.operation"` strings that should be accepted
609    /// by preflight without emitting a diagnostic, even if the operation
610    /// is not in the default or loaded capability manifest.
611    #[serde(default, alias = "preflight-allow")]
612    pub preflight_allow: Vec<String>,
613}
614
615#[derive(Debug, Default, Clone, Deserialize)]
616pub struct WorkspaceConfig {
617    /// Directory or file globs (repo-relative) that `harn check --workspace`
618    /// walks to collect the full pipeline tree in one invocation.
619    #[serde(default)]
620    pub pipelines: Vec<String>,
621}
622
623#[derive(Debug, Default, Clone, Deserialize)]
624pub struct PackageRegistryConfig {
625    /// URL or filesystem path to a TOML package index.
626    #[serde(default)]
627    pub url: Option<String>,
628}
629
630#[derive(Debug, Clone, Deserialize)]
631pub struct McpServerConfig {
632    pub name: String,
633    #[serde(default)]
634    pub transport: Option<String>,
635    #[serde(default)]
636    pub command: String,
637    #[serde(default)]
638    pub args: Vec<String>,
639    #[serde(default)]
640    pub env: HashMap<String, String>,
641    #[serde(default)]
642    pub url: String,
643    #[serde(default)]
644    pub auth_token: Option<String>,
645    #[serde(default)]
646    pub client_id: Option<String>,
647    #[serde(default)]
648    pub client_secret: Option<String>,
649    #[serde(default)]
650    pub scopes: Option<String>,
651    #[serde(default)]
652    pub protocol_version: Option<String>,
653    #[serde(default)]
654    pub proxy_server_name: Option<String>,
655    /// When `true`, the server is NOT booted up-front. It boots on the
656    /// first `mcp_call` or on skill activation that declares it in
657    /// `requires_mcp`. See harn#75.
658    #[serde(default)]
659    pub lazy: bool,
660    /// Optional pointer to a Server Card — either an HTTP(S) URL or a
661    /// local filesystem path. When set, `mcp_server_card("name")` reads
662    /// the card from this source (cached per-process with a TTL).
663    #[serde(default)]
664    pub card: Option<String>,
665    /// How long (milliseconds) to keep a lazy server's process alive
666    /// after its last binder releases. 0 / unset → disconnect
667    /// immediately. Ignored for non-lazy servers.
668    #[serde(default, alias = "keep-alive-ms", alias = "keep_alive")]
669    pub keep_alive_ms: Option<u64>,
670}
671
672#[derive(Debug, Clone, Deserialize)]
673#[allow(dead_code)] // Package metadata feeds authoring/publish validation tracked in harn#471.
674pub struct PackageInfo {
675    pub name: Option<String>,
676    pub version: Option<String>,
677    #[serde(default)]
678    pub evals: Vec<String>,
679    #[serde(default)]
680    pub description: Option<String>,
681    #[serde(default)]
682    pub license: Option<String>,
683    #[serde(default)]
684    pub repository: Option<String>,
685    #[serde(default, alias = "harn_version", alias = "harn_version_range")]
686    pub harn: Option<String>,
687    #[serde(default)]
688    pub docs_url: Option<String>,
689    #[serde(default)]
690    pub provenance: Option<String>,
691    #[serde(default)]
692    pub permissions: Vec<String>,
693    #[serde(default, alias = "host-requirements")]
694    pub host_requirements: Vec<String>,
695    #[serde(default)]
696    pub tools: Vec<PackageToolExport>,
697    #[serde(default)]
698    pub skills: Vec<PackageSkillExport>,
699}
700
701#[derive(Debug, Clone, Deserialize, PartialEq)]
702pub struct PackageToolExport {
703    pub name: String,
704    pub module: String,
705    #[serde(default = "default_package_tool_symbol")]
706    pub symbol: String,
707    #[serde(default)]
708    pub description: Option<String>,
709    #[serde(default)]
710    pub permissions: Vec<String>,
711    #[serde(default, alias = "host-requirements")]
712    pub host_requirements: Vec<String>,
713    #[serde(default, alias = "input-schema")]
714    pub input_schema: Option<toml::Value>,
715    #[serde(default, alias = "output-schema")]
716    pub output_schema: Option<toml::Value>,
717    #[serde(default)]
718    pub annotations: BTreeMap<String, toml::Value>,
719}
720
721pub(crate) fn default_package_tool_symbol() -> String {
722    "tools".to_string()
723}
724
725#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
726pub struct PackageSkillExport {
727    pub name: String,
728    pub path: String,
729    #[serde(default)]
730    pub description: Option<String>,
731    #[serde(default)]
732    pub permissions: Vec<String>,
733    #[serde(default, alias = "host-requirements")]
734    pub host_requirements: Vec<String>,
735}
736
737#[derive(Debug, Clone, Deserialize)]
738#[serde(untagged)]
739pub enum Dependency {
740    Table(DepTable),
741    Path(String),
742}
743
744#[derive(Debug, Clone, Default, Deserialize)]
745pub struct DepTable {
746    pub git: Option<String>,
747    pub tag: Option<String>,
748    pub rev: Option<String>,
749    pub branch: Option<String>,
750    pub path: Option<String>,
751    pub package: Option<String>,
752    /// Registry index URL/path the dependency was originally added from.
753    /// Persisted in the manifest so registry provenance survives
754    /// round-trips and the lockfile can compare against the registry's
755    /// latest version.
756    #[serde(default)]
757    pub registry: Option<String>,
758    /// Registry-side package name (e.g. `@burin/notion-sdk`). May differ
759    /// from the alias and from the git URL's repo name.
760    #[serde(default, alias = "registry-name")]
761    pub registry_name: Option<String>,
762    /// Registry version specifier the dependency was added against.
763    #[serde(default, alias = "registry-version")]
764    pub registry_version: Option<String>,
765}
766
767impl Dependency {
768    pub(crate) fn git_url(&self) -> Option<&str> {
769        match self {
770            Dependency::Table(t) => t.git.as_deref(),
771            Dependency::Path(_) => None,
772        }
773    }
774
775    pub(crate) fn rev(&self) -> Option<&str> {
776        match self {
777            Dependency::Table(t) => t.rev.as_deref().or(t.tag.as_deref()),
778            Dependency::Path(_) => None,
779        }
780    }
781
782    pub(crate) fn branch(&self) -> Option<&str> {
783        match self {
784            Dependency::Table(t) => t.branch.as_deref(),
785            Dependency::Path(_) => None,
786        }
787    }
788
789    pub(crate) fn local_path(&self) -> Option<&str> {
790        match self {
791            Dependency::Table(t) => t.path.as_deref(),
792            Dependency::Path(p) => Some(p.as_str()),
793        }
794    }
795
796    pub(crate) fn registry_provenance(&self) -> Option<crate::package::RegistryProvenance> {
797        let Dependency::Table(table) = self else {
798            return None;
799        };
800        let source = table.registry.clone()?;
801        let name = table.registry_name.clone()?;
802        let version = table.registry_version.clone()?;
803        Some(crate::package::RegistryProvenance {
804            source,
805            name,
806            version,
807            provenance_url: None,
808        })
809    }
810}
811
812pub(crate) fn validate_package_alias(alias: &str) -> Result<(), PackageError> {
813    let valid = !alias.is_empty()
814        && alias != "."
815        && alias != ".."
816        && alias
817            .bytes()
818            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'));
819    if valid {
820        Ok(())
821    } else {
822        Err(PackageError::Validation(format!(
823            "invalid dependency alias {alias:?}; use ASCII letters, numbers, '.', '_' or '-'"
824        )))
825    }
826}
827
828pub(crate) fn toml_string_literal(value: &str) -> Result<String, PackageError> {
829    use std::fmt::Write as _;
830
831    let mut encoded = String::with_capacity(value.len() + 2);
832    encoded.push('"');
833    for ch in value.chars() {
834        match ch {
835            '\u{08}' => encoded.push_str("\\b"),
836            '\t' => encoded.push_str("\\t"),
837            '\n' => encoded.push_str("\\n"),
838            '\u{0C}' => encoded.push_str("\\f"),
839            '\r' => encoded.push_str("\\r"),
840            '"' => encoded.push_str("\\\""),
841            '\\' => encoded.push_str("\\\\"),
842            ch if ch <= '\u{1F}' || ch == '\u{7F}' => {
843                write!(&mut encoded, "\\u{:04X}", ch as u32).map_err(|error| {
844                    PackageError::Manifest(format!("failed to encode TOML string: {error}"))
845                })?;
846            }
847            ch => encoded.push(ch),
848        }
849    }
850    encoded.push('"');
851    Ok(encoded)
852}
853
854#[derive(Debug, Default, Clone)]
855pub struct RuntimeExtensions {
856    pub root_manifest: Option<Manifest>,
857    pub root_manifest_path: Option<PathBuf>,
858    pub root_manifest_dir: Option<PathBuf>,
859    pub llm: Option<harn_vm::llm_config::ProvidersConfig>,
860    pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
861    pub hooks: Vec<ResolvedHookConfig>,
862    pub triggers: Vec<ResolvedTriggerConfig>,
863    pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
864    pub provider_connectors: Vec<ResolvedProviderConnectorConfig>,
865}
866
867#[derive(Debug, Clone, Deserialize)]
868pub struct ProviderManifestEntry {
869    pub id: harn_vm::ProviderId,
870    pub connector: ProviderConnectorManifest,
871    #[serde(default)]
872    pub oauth: Option<ProviderOAuthManifest>,
873    #[serde(default)]
874    pub setup: Option<ProviderSetupManifest>,
875    #[serde(default)]
876    pub capabilities: ConnectorCapabilities,
877}
878
879#[derive(Debug, Clone, Deserialize)]
880pub struct ProviderConnectorManifest {
881    #[serde(default)]
882    pub harn: Option<String>,
883    #[serde(default)]
884    pub rust: Option<String>,
885}
886
887#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
888pub struct ProviderOAuthManifest {
889    #[serde(default, alias = "auth_url", alias = "authorization-endpoint")]
890    pub authorization_endpoint: Option<String>,
891    #[serde(default, alias = "token_url", alias = "token-endpoint")]
892    pub token_endpoint: Option<String>,
893    #[serde(default, alias = "registration_url", alias = "registration-endpoint")]
894    pub registration_endpoint: Option<String>,
895    #[serde(default)]
896    pub resource: Option<String>,
897    #[serde(default, alias = "scope")]
898    pub scopes: Option<String>,
899    #[serde(default, alias = "client-id")]
900    pub client_id: Option<String>,
901    #[serde(default, alias = "client-secret")]
902    pub client_secret: Option<String>,
903    #[serde(default, alias = "token_auth_method", alias = "token-auth-method")]
904    pub token_endpoint_auth_method: Option<String>,
905}
906
907#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
908pub struct ProviderSetupManifest {
909    #[serde(default, alias = "auth-type")]
910    pub auth_type: Option<String>,
911    #[serde(default)]
912    pub flow: Option<String>,
913    #[serde(default, alias = "required-scopes", alias = "scopes")]
914    pub required_scopes: Vec<String>,
915    #[serde(default, alias = "required-secrets")]
916    pub required_secrets: Vec<String>,
917    #[serde(default, alias = "setup-command")]
918    pub setup_command: Vec<String>,
919    #[serde(default, alias = "validation-command")]
920    pub validation_command: Vec<String>,
921    #[serde(default, alias = "health-checks")]
922    pub health_checks: Vec<ConnectorHealthCheckManifest>,
923    #[serde(default)]
924    pub recovery: ConnectorRecoveryCopy,
925    #[serde(flatten, default)]
926    pub extra: BTreeMap<String, toml::Value>,
927}
928
929#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
930pub struct ConnectorHealthCheckManifest {
931    pub id: String,
932    pub kind: String,
933    #[serde(default)]
934    pub command: Vec<String>,
935    #[serde(default)]
936    pub secret: Option<String>,
937    #[serde(default)]
938    pub url: Option<String>,
939}
940
941#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
942pub struct ConnectorRecoveryCopy {
943    #[serde(default, alias = "missing-install")]
944    pub missing_install: Option<String>,
945    #[serde(default, alias = "missing-auth")]
946    pub missing_auth: Option<String>,
947    #[serde(default, alias = "expired-credentials")]
948    pub expired_credentials: Option<String>,
949    #[serde(default, alias = "revoked-credentials")]
950    pub revoked_credentials: Option<String>,
951    #[serde(default, alias = "missing-scopes")]
952    pub missing_scopes: Option<String>,
953    #[serde(default, alias = "inaccessible-resource")]
954    pub inaccessible_resource: Option<String>,
955    #[serde(default, alias = "transient-provider-outage")]
956    pub transient_provider_outage: Option<String>,
957}
958
959#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
960pub struct ConnectorCapabilities {
961    pub webhook: bool,
962    pub oauth: bool,
963    pub rate_limit: bool,
964    pub pagination: bool,
965    pub graphql: bool,
966    pub streaming: bool,
967}
968
969impl ConnectorCapabilities {
970    pub const FEATURES: [&'static str; 6] = [
971        "webhook",
972        "oauth",
973        "rate_limit",
974        "pagination",
975        "graphql",
976        "streaming",
977    ];
978
979    fn enable(&mut self, feature: &str) -> Result<(), String> {
980        match normalize_connector_capability(feature).as_str() {
981            "webhook" => self.webhook = true,
982            "oauth" => self.oauth = true,
983            "rate_limit" => self.rate_limit = true,
984            "pagination" => self.pagination = true,
985            "graphql" => self.graphql = true,
986            "streaming" => self.streaming = true,
987            other => {
988                return Err(format!(
989                    "unknown connector capability '{feature}' (normalized as '{other}')"
990                ));
991            }
992        }
993        Ok(())
994    }
995}
996
997#[derive(Debug, Default, Deserialize)]
998struct ConnectorCapabilitiesTable {
999    #[serde(default)]
1000    webhook: bool,
1001    #[serde(default)]
1002    oauth: bool,
1003    #[serde(default, alias = "rate-limit")]
1004    rate_limit: bool,
1005    #[serde(default)]
1006    pagination: bool,
1007    #[serde(default)]
1008    graphql: bool,
1009    #[serde(default)]
1010    streaming: bool,
1011}
1012
1013impl From<ConnectorCapabilitiesTable> for ConnectorCapabilities {
1014    fn from(value: ConnectorCapabilitiesTable) -> Self {
1015        Self {
1016            webhook: value.webhook,
1017            oauth: value.oauth,
1018            rate_limit: value.rate_limit,
1019            pagination: value.pagination,
1020            graphql: value.graphql,
1021            streaming: value.streaming,
1022        }
1023    }
1024}
1025
1026impl<'de> Deserialize<'de> for ConnectorCapabilities {
1027    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1028    where
1029        D: serde::Deserializer<'de>,
1030    {
1031        #[derive(Deserialize)]
1032        #[serde(untagged)]
1033        enum RawConnectorCapabilities {
1034            List(Vec<String>),
1035            Table(ConnectorCapabilitiesTable),
1036        }
1037
1038        match RawConnectorCapabilities::deserialize(deserializer)? {
1039            RawConnectorCapabilities::List(features) => {
1040                let mut capabilities = ConnectorCapabilities::default();
1041                for feature in features {
1042                    capabilities
1043                        .enable(&feature)
1044                        .map_err(serde::de::Error::custom)?;
1045                }
1046                Ok(capabilities)
1047            }
1048            RawConnectorCapabilities::Table(table) => Ok(table.into()),
1049        }
1050    }
1051}
1052
1053pub fn normalize_connector_capability(feature: &str) -> String {
1054    feature.trim().to_lowercase().replace('-', "_")
1055}
1056
1057#[derive(Debug, Clone, Default, Deserialize)]
1058pub struct ConnectorContractConfig {
1059    #[serde(default)]
1060    pub version: Option<u32>,
1061    #[serde(default)]
1062    pub fixtures: Vec<ConnectorContractFixture>,
1063}
1064
1065#[derive(Debug, Clone, Deserialize)]
1066pub struct ConnectorContractFixture {
1067    pub provider: harn_vm::ProviderId,
1068    #[serde(default)]
1069    pub name: Option<String>,
1070    #[serde(default)]
1071    pub kind: Option<String>,
1072    #[serde(default)]
1073    pub headers: BTreeMap<String, String>,
1074    #[serde(default)]
1075    pub query: BTreeMap<String, String>,
1076    #[serde(default)]
1077    pub metadata: Option<toml::Value>,
1078    #[serde(default)]
1079    pub body: Option<String>,
1080    #[serde(default)]
1081    pub body_json: Option<toml::Value>,
1082    #[serde(default)]
1083    pub expect_type: Option<String>,
1084    #[serde(default)]
1085    pub expect_kind: Option<String>,
1086    #[serde(default)]
1087    pub expect_dedupe_key: Option<String>,
1088    #[serde(default)]
1089    pub expect_signature_state: Option<String>,
1090    #[serde(default)]
1091    pub expect_payload_contains: Option<toml::Value>,
1092    #[serde(default)]
1093    pub expect_response_status: Option<u16>,
1094    #[serde(default)]
1095    pub expect_response_body: Option<toml::Value>,
1096    #[serde(default)]
1097    pub expect_event_count: Option<usize>,
1098    #[serde(default)]
1099    pub expect_error_contains: Option<String>,
1100}
1101
1102#[derive(Debug, Clone, PartialEq, Eq)]
1103pub enum ResolvedProviderConnectorKind {
1104    Harn { module: String },
1105    RustBuiltin,
1106    Invalid(String),
1107}
1108
1109#[derive(Debug, Clone)]
1110pub struct ResolvedProviderConnectorConfig {
1111    pub id: harn_vm::ProviderId,
1112    pub manifest_dir: PathBuf,
1113    pub connector: ResolvedProviderConnectorKind,
1114    pub oauth: Option<ProviderOAuthManifest>,
1115    pub setup: Option<ProviderSetupManifest>,
1116}
1117
1118#[derive(Debug, Clone)]
1119pub struct ResolvedHookConfig {
1120    pub event: harn_vm::orchestration::HookEvent,
1121    pub pattern: String,
1122    pub handler: String,
1123    pub manifest_dir: PathBuf,
1124    pub package_name: Option<String>,
1125    pub exports: HashMap<String, String>,
1126}
1127
1128#[derive(Debug, Clone)]
1129#[allow(dead_code)] // Trigger metadata is carried forward for harn#156 doctor and harn#159 dispatcher work.
1130pub struct ResolvedTriggerConfig {
1131    pub id: String,
1132    pub kind: TriggerKind,
1133    pub provider: harn_vm::ProviderId,
1134    pub autonomy_tier: harn_vm::AutonomyTier,
1135    pub match_: TriggerMatchExpr,
1136    pub when: Option<String>,
1137    pub when_budget: Option<TriggerWhenBudgetSpec>,
1138    pub handler: String,
1139    pub dedupe_key: Option<String>,
1140    pub retry: TriggerRetrySpec,
1141    pub dispatch_priority: TriggerDispatchPriority,
1142    pub budget: TriggerBudgetSpec,
1143    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
1144    pub throttle: Option<TriggerThrottleManifestSpec>,
1145    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
1146    pub debounce: Option<TriggerDebounceManifestSpec>,
1147    pub singleton: Option<TriggerSingletonManifestSpec>,
1148    pub batch: Option<TriggerBatchManifestSpec>,
1149    pub window: Option<TriggerStreamWindowManifestSpec>,
1150    pub priority_flow: Option<TriggerPriorityManifestSpec>,
1151    pub secrets: BTreeMap<String, String>,
1152    pub filter: Option<String>,
1153    pub kind_specific: BTreeMap<String, toml::Value>,
1154    pub manifest_dir: PathBuf,
1155    pub manifest_path: PathBuf,
1156    pub package_name: Option<String>,
1157    pub exports: HashMap<String, String>,
1158    pub table_index: usize,
1159    pub shape_error: Option<String>,
1160}
1161
1162#[derive(Debug, Clone)]
1163#[allow(dead_code)] // Collected bindings are validated now and consumed by harn#159 dispatcher work.
1164pub struct CollectedManifestTrigger {
1165    pub config: ResolvedTriggerConfig,
1166    pub handler: CollectedTriggerHandler,
1167    pub when: Option<CollectedTriggerPredicate>,
1168    pub flow_control: harn_vm::TriggerFlowControlConfig,
1169}
1170
1171#[derive(Debug, Clone)]
1172#[allow(dead_code)] // Remote targets and closures are retained for harn#159 trigger execution.
1173pub enum CollectedTriggerHandler {
1174    Local {
1175        reference: TriggerFunctionRef,
1176        closure: Rc<harn_vm::VmClosure>,
1177    },
1178    A2a {
1179        target: String,
1180        allow_cleartext: bool,
1181    },
1182    Worker {
1183        queue: String,
1184    },
1185    Persona {
1186        binding: harn_vm::PersonaRuntimeBinding,
1187    },
1188}
1189
1190#[derive(Debug, Clone)]
1191#[allow(dead_code)] // Predicate closures are validated now and reused by harn#161 dispatch gating.
1192pub struct CollectedTriggerPredicate {
1193    pub reference: TriggerFunctionRef,
1194    pub closure: Rc<harn_vm::VmClosure>,
1195}
1196
1197pub(crate) type ManifestModuleCacheKey = (PathBuf, Option<String>, Option<String>);
1198pub(crate) type ManifestModuleExports = BTreeMap<String, Rc<harn_vm::VmClosure>>;
1199
1200static MANIFEST_PROVIDER_SCHEMA_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
1201
1202pub(crate) async fn lock_manifest_provider_schemas() -> tokio::sync::MutexGuard<'static, ()> {
1203    MANIFEST_PROVIDER_SCHEMA_LOCK
1204        .get_or_init(|| tokio::sync::Mutex::new(()))
1205        .lock()
1206        .await
1207}
1208
1209pub(crate) fn read_manifest_from_path(path: &Path) -> Result<Manifest, PackageError> {
1210    let content = fs::read_to_string(path).map_err(|error| {
1211        if error.kind() == std::io::ErrorKind::NotFound {
1212            PackageError::Manifest(format!(
1213                "No {} found in {}.",
1214                MANIFEST,
1215                path.parent().unwrap_or_else(|| Path::new(".")).display()
1216            ))
1217        } else {
1218            PackageError::Manifest(format!("failed to read {}: {error}", path.display()))
1219        }
1220    })?;
1221    toml::from_str::<Manifest>(&content).map_err(|error| {
1222        PackageError::Manifest(format!("failed to parse {}: {error}", path.display()))
1223    })
1224}
1225
1226pub(crate) fn write_manifest_content(path: &Path, content: &str) -> Result<(), PackageError> {
1227    harn_vm::atomic_io::atomic_write(path, content.as_bytes()).map_err(|error| {
1228        PackageError::Manifest(format!("failed to write {}: {error}", path.display()))
1229    })
1230}
1231
1232pub(crate) fn absolutize_check_config_paths(
1233    mut config: CheckConfig,
1234    manifest_dir: &Path,
1235) -> CheckConfig {
1236    if let Some(path) = config.host_capabilities_path.clone() {
1237        let candidate = PathBuf::from(&path);
1238        if !candidate.is_absolute() {
1239            config.host_capabilities_path =
1240                Some(manifest_dir.join(candidate).display().to_string());
1241        }
1242    }
1243    if let Some(path) = config.bundle_root.clone() {
1244        let candidate = PathBuf::from(&path);
1245        if !candidate.is_absolute() {
1246            config.bundle_root = Some(manifest_dir.join(candidate).display().to_string());
1247        }
1248    }
1249    config
1250}
1251
1252/// Walk upward from `start` (or its parent if it's a file path that
1253/// does not yet exist) looking for the nearest `harn.toml`. Stops at
1254/// a `.git` boundary so a stray manifest in `$HOME` or a parent
1255/// project is never silently picked up. Returns `(manifest, manifest_dir)`
1256/// when found.
1257pub(crate) fn find_nearest_manifest(start: &Path) -> Option<(Manifest, PathBuf)> {
1258    const MAX_PARENT_DIRS: usize = 16;
1259    let base = if start.is_absolute() {
1260        start.to_path_buf()
1261    } else {
1262        std::env::current_dir()
1263            .unwrap_or_else(|_| PathBuf::from("."))
1264            .join(start)
1265    };
1266    let mut cursor: Option<PathBuf> = if base.is_dir() {
1267        Some(base)
1268    } else {
1269        base.parent().map(Path::to_path_buf)
1270    };
1271    let mut steps = 0usize;
1272    while let Some(dir) = cursor {
1273        if steps >= MAX_PARENT_DIRS {
1274            break;
1275        }
1276        steps += 1;
1277        let candidate = dir.join(MANIFEST);
1278        if candidate.is_file() {
1279            match read_manifest_from_path(&candidate) {
1280                Ok(manifest) => return Some((manifest, dir)),
1281                Err(error) => {
1282                    eprintln!("warning: {error}");
1283                    return None;
1284                }
1285            }
1286        }
1287        if dir.join(".git").exists() {
1288            break;
1289        }
1290        cursor = dir.parent().map(Path::to_path_buf);
1291    }
1292    None
1293}
1294
1295/// Load the `[check]` config from the nearest `harn.toml`.
1296/// Walks up from the given file (or from cwd if no file is given),
1297/// stopping at a `.git` boundary.
1298pub fn load_check_config(harn_file: Option<&std::path::Path>) -> CheckConfig {
1299    let anchor = harn_file
1300        .map(Path::to_path_buf)
1301        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1302    if let Some((manifest, dir)) = find_nearest_manifest(&anchor) {
1303        return absolutize_check_config_paths(manifest.check, &dir);
1304    }
1305    CheckConfig::default()
1306}
1307
1308/// Load the `[workspace]` config and the directory of the `harn.toml`
1309/// it came from. Paths in the returned config are left as-is (callers
1310/// resolve them against the returned `manifest_dir`).
1311pub fn load_workspace_config(anchor: Option<&Path>) -> Option<(WorkspaceConfig, PathBuf)> {
1312    let anchor = anchor
1313        .map(Path::to_path_buf)
1314        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1315    let (manifest, dir) = find_nearest_manifest(&anchor)?;
1316    Some((manifest.workspace, dir))
1317}
1318
1319pub fn load_package_eval_pack_paths(anchor: Option<&Path>) -> Result<Vec<PathBuf>, PackageError> {
1320    let anchor = anchor
1321        .map(Path::to_path_buf)
1322        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1323    let Some((manifest, dir)) = find_nearest_manifest(&anchor) else {
1324        return Err(PackageError::Manifest(
1325            "no harn.toml found for package eval discovery".to_string(),
1326        ));
1327    };
1328
1329    let declared = manifest
1330        .package
1331        .as_ref()
1332        .map(|package| package.evals.clone())
1333        .unwrap_or_default();
1334    let mut paths = if declared.is_empty() {
1335        let default_pack = dir.join("harn.eval.toml");
1336        if default_pack.is_file() {
1337            vec![default_pack]
1338        } else {
1339            Vec::new()
1340        }
1341    } else {
1342        declared
1343            .iter()
1344            .map(|entry| {
1345                let path = PathBuf::from(entry);
1346                if path.is_absolute() {
1347                    path
1348                } else {
1349                    dir.join(path)
1350                }
1351            })
1352            .collect()
1353    };
1354    paths.sort();
1355    if paths.is_empty() {
1356        return Err(PackageError::Manifest(
1357            "package declares no eval packs; add [package].evals or harn.eval.toml".to_string(),
1358        ));
1359    }
1360    for path in &paths {
1361        if !path.is_file() {
1362            return Err(PackageError::Manifest(format!(
1363                "eval pack does not exist: {}",
1364                path.display()
1365            )));
1366        }
1367    }
1368    Ok(paths)
1369}
1370
1371#[derive(Debug, Clone)]
1372pub(crate) struct ManifestContext {
1373    pub(crate) manifest: Manifest,
1374    pub(crate) dir: PathBuf,
1375}
1376
1377impl ManifestContext {
1378    pub(crate) fn manifest_path(&self) -> PathBuf {
1379        self.dir.join(MANIFEST)
1380    }
1381
1382    pub(crate) fn lock_path(&self) -> PathBuf {
1383        self.dir.join(LOCK_FILE)
1384    }
1385
1386    pub(crate) fn packages_dir(&self) -> PathBuf {
1387        self.dir.join(PKG_DIR)
1388    }
1389}
1390
1391#[derive(Debug, Clone)]
1392pub(crate) struct PackageWorkspace {
1393    manifest_dir: PathBuf,
1394    cache_dir: Option<PathBuf>,
1395    registry_source: Option<String>,
1396    read_process_env: bool,
1397}
1398
1399impl PackageWorkspace {
1400    pub(crate) fn from_current_dir() -> Result<Self, PackageError> {
1401        let manifest_dir = std::env::current_dir()
1402            .map_err(|error| PackageError::Manifest(format!("failed to read cwd: {error}")))?;
1403        Ok(Self {
1404            manifest_dir,
1405            cache_dir: None,
1406            registry_source: None,
1407            read_process_env: true,
1408        })
1409    }
1410
1411    #[cfg(test)]
1412    pub(crate) fn for_test(
1413        manifest_dir: impl Into<PathBuf>,
1414        cache_dir: impl Into<PathBuf>,
1415    ) -> Self {
1416        Self {
1417            manifest_dir: manifest_dir.into(),
1418            cache_dir: Some(cache_dir.into()),
1419            registry_source: None,
1420            read_process_env: false,
1421        }
1422    }
1423
1424    #[cfg(test)]
1425    pub(crate) fn with_registry_source(mut self, source: impl Into<String>) -> Self {
1426        self.registry_source = Some(source.into());
1427        self
1428    }
1429
1430    pub(crate) fn manifest_dir(&self) -> &Path {
1431        &self.manifest_dir
1432    }
1433
1434    pub(crate) fn load_manifest_context(&self) -> Result<ManifestContext, PackageError> {
1435        let manifest_path = self.manifest_dir.join(MANIFEST);
1436        let manifest = read_manifest_from_path(&manifest_path)?;
1437        Ok(ManifestContext {
1438            manifest,
1439            dir: self.manifest_dir.clone(),
1440        })
1441    }
1442
1443    pub(crate) fn cache_root(&self) -> Result<PathBuf, PackageError> {
1444        if let Some(cache_dir) = &self.cache_dir {
1445            return Ok(cache_dir.clone());
1446        }
1447        if self.read_process_env {
1448            if let Ok(value) = std::env::var(HARN_CACHE_DIR_ENV) {
1449                if !value.trim().is_empty() {
1450                    return Ok(PathBuf::from(value));
1451                }
1452            }
1453        }
1454
1455        let home = std::env::var_os("HOME")
1456            .map(PathBuf::from)
1457            .ok_or_else(|| "HOME is not set and HARN_CACHE_DIR was not provided".to_string())?;
1458        if cfg!(target_os = "macos") {
1459            return Ok(home.join("Library/Caches/harn"));
1460        }
1461        if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
1462            return Ok(PathBuf::from(xdg).join("harn"));
1463        }
1464        Ok(home.join(".cache/harn"))
1465    }
1466
1467    pub(crate) fn resolve_registry_source(
1468        &self,
1469        explicit: Option<&str>,
1470    ) -> Result<String, PackageError> {
1471        if let Some(explicit) = explicit.map(str::trim).filter(|value| !value.is_empty()) {
1472            return Ok(explicit.to_string());
1473        }
1474        if let Some(source) = self
1475            .registry_source
1476            .as_deref()
1477            .map(str::trim)
1478            .filter(|value| !value.is_empty())
1479        {
1480            if Url::parse(source).is_ok() || PathBuf::from(source).is_absolute() {
1481                return Ok(source.to_string());
1482            }
1483            return Ok(self.manifest_dir.join(source).display().to_string());
1484        }
1485        if self.read_process_env {
1486            if let Ok(value) = std::env::var(HARN_PACKAGE_REGISTRY_ENV) {
1487                let value = value.trim();
1488                if !value.is_empty() {
1489                    return Ok(value.to_string());
1490                }
1491            }
1492        }
1493
1494        if let Some((manifest, manifest_dir)) = find_nearest_manifest(&self.manifest_dir) {
1495            if let Some(raw) = manifest
1496                .registry
1497                .url
1498                .as_deref()
1499                .map(str::trim)
1500                .filter(|value| !value.is_empty())
1501            {
1502                if Url::parse(raw).is_ok() || PathBuf::from(raw).is_absolute() {
1503                    return Ok(raw.to_string());
1504                }
1505                return Ok(manifest_dir.join(raw).display().to_string());
1506            }
1507        }
1508
1509        Ok(DEFAULT_PACKAGE_REGISTRY_URL.to_string())
1510    }
1511}
1512
1513#[cfg(test)]
1514mod tests {
1515    use super::*;
1516
1517    #[test]
1518    fn package_eval_pack_paths_use_package_manifest_entries() {
1519        let tmp = tempfile::tempdir().unwrap();
1520        let root = tmp.path();
1521        fs::create_dir_all(root.join(".git")).unwrap();
1522        fs::create_dir_all(root.join("evals")).unwrap();
1523        fs::write(
1524            root.join(MANIFEST),
1525            r#"
1526    [package]
1527    name = "demo"
1528    version = "0.1.0"
1529    evals = ["evals/webhook.toml"]
1530    "#,
1531        )
1532        .unwrap();
1533        fs::write(
1534            root.join("evals/webhook.toml"),
1535            "version = 1\n[[cases]]\nrun = \"run.json\"\n",
1536        )
1537        .unwrap();
1538
1539        let paths = load_package_eval_pack_paths(Some(&root.join("src/main.harn"))).unwrap();
1540
1541        assert_eq!(paths, vec![root.join("evals/webhook.toml")]);
1542    }
1543    #[test]
1544    fn preflight_severity_parsing_accepts_synonyms() {
1545        assert_eq!(
1546            PreflightSeverity::from_opt(Some("warning")),
1547            PreflightSeverity::Warning
1548        );
1549        assert_eq!(
1550            PreflightSeverity::from_opt(Some("WARN")),
1551            PreflightSeverity::Warning
1552        );
1553        assert_eq!(
1554            PreflightSeverity::from_opt(Some("off")),
1555            PreflightSeverity::Off
1556        );
1557        assert_eq!(
1558            PreflightSeverity::from_opt(Some("allow")),
1559            PreflightSeverity::Off
1560        );
1561        assert_eq!(
1562            PreflightSeverity::from_opt(Some("error")),
1563            PreflightSeverity::Error
1564        );
1565        assert_eq!(PreflightSeverity::from_opt(None), PreflightSeverity::Error);
1566        // Unknown values fall back to the safe default (error).
1567        assert_eq!(
1568            PreflightSeverity::from_opt(Some("bogus")),
1569            PreflightSeverity::Error
1570        );
1571    }
1572
1573    #[test]
1574    fn load_check_config_walks_up_from_nested_file() {
1575        let tmp = tempfile::tempdir().unwrap();
1576        let root = tmp.path();
1577        // Mark root as project boundary so walk-up terminates here.
1578        std::fs::create_dir_all(root.join(".git")).unwrap();
1579        fs::write(
1580            root.join(MANIFEST),
1581            r#"
1582    [check]
1583    preflight_severity = "warning"
1584    preflight_allow = ["custom.scan", "runtime.*"]
1585    host_capabilities_path = "./schemas/host-caps.json"
1586
1587    [workspace]
1588    pipelines = ["pipelines", "scripts"]
1589    "#,
1590        )
1591        .unwrap();
1592        let nested = root.join("src").join("deep");
1593        std::fs::create_dir_all(&nested).unwrap();
1594        let harn_file = nested.join("pipeline.harn");
1595        fs::write(&harn_file, "pipeline main() {}\n").unwrap();
1596
1597        let cfg = load_check_config(Some(&harn_file));
1598        assert_eq!(cfg.preflight_severity.as_deref(), Some("warning"));
1599        assert_eq!(cfg.preflight_allow, vec!["custom.scan", "runtime.*"]);
1600        let caps_path = cfg.host_capabilities_path.expect("host caps path");
1601        assert!(
1602            caps_path.ends_with("schemas/host-caps.json")
1603                || caps_path.ends_with("schemas\\host-caps.json"),
1604            "unexpected absolutized path: {caps_path}"
1605        );
1606
1607        let (workspace, manifest_dir) =
1608            load_workspace_config(Some(&harn_file)).expect("workspace manifest");
1609        assert_eq!(workspace.pipelines, vec!["pipelines", "scripts"]);
1610        // Walk-up lands on the directory containing the harn.toml.
1611        assert_eq!(manifest_dir, root);
1612    }
1613
1614    #[test]
1615    fn orchestrator_drain_config_parses_defaults_and_overrides() {
1616        let default_manifest: Manifest = toml::from_str(
1617            r#"
1618    [package]
1619    name = "fixture"
1620    "#,
1621        )
1622        .unwrap();
1623        assert_eq!(default_manifest.orchestrator.drain.max_items, 1024);
1624        assert_eq!(default_manifest.orchestrator.drain.deadline_seconds, 30);
1625        assert_eq!(default_manifest.orchestrator.pumps.max_outstanding, 64);
1626
1627        let configured: Manifest = toml::from_str(
1628            r#"
1629    [package]
1630    name = "fixture"
1631
1632    [orchestrator]
1633    drain.max_items = 77
1634    drain.deadline_seconds = 12
1635    pumps.max_outstanding = 3
1636    "#,
1637        )
1638        .unwrap();
1639        assert_eq!(configured.orchestrator.drain.max_items, 77);
1640        assert_eq!(configured.orchestrator.drain.deadline_seconds, 12);
1641        assert_eq!(configured.orchestrator.pumps.max_outstanding, 3);
1642    }
1643
1644    #[test]
1645    fn load_check_config_stops_at_git_boundary() {
1646        let tmp = tempfile::tempdir().unwrap();
1647        // An ancestor harn.toml above .git must NOT be picked up.
1648        fs::write(
1649            tmp.path().join(MANIFEST),
1650            "[check]\npreflight_severity = \"off\"\n",
1651        )
1652        .unwrap();
1653        let project = tmp.path().join("project");
1654        std::fs::create_dir_all(project.join(".git")).unwrap();
1655        let inner = project.join("src");
1656        std::fs::create_dir_all(&inner).unwrap();
1657        let harn_file = inner.join("main.harn");
1658        fs::write(&harn_file, "pipeline main() {}\n").unwrap();
1659        let cfg = load_check_config(Some(&harn_file));
1660        assert!(
1661            cfg.preflight_severity.is_none(),
1662            "must not inherit harn.toml from outside the .git boundary"
1663        );
1664    }
1665}