Skip to main content

harn_cli/package/
manifest.rs

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