1use std::{
4 collections::BTreeMap,
5 fmt::{Display, Formatter},
6 num::{NonZeroU32, NonZeroUsize},
7 path::PathBuf,
8 time::{SystemTime, UNIX_EPOCH},
9};
10
11use camino::{Utf8Path, Utf8PathBuf};
12use globset::Glob;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use crate::diagnostic::Diagnostic;
17
18const NAME_MAX_BYTES: usize = 128;
19const OWNER_MAX_BYTES: usize = 64;
20const TASK_MAX_BYTES: usize = 64;
21const SCHEMA_MAX_BYTES: usize = 96;
22const PATH_MAX_BYTES: usize = 512;
23const COMMAND_PART_MAX_BYTES: usize = 256;
24const COMMAND_ARG_LIMIT: usize = 64;
25
26#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
28#[serde(rename_all = "kebab-case")]
29pub enum RepoLayout {
30 Functional,
32}
33
34#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
36#[serde(transparent)]
37pub struct RepoName(String);
38
39impl RepoName {
40 pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
42 let value = value.into();
43 validate_ascii_identifier("repo name", &value, NAME_MAX_BYTES, false)?;
44 Ok(Self(value))
45 }
46
47 pub fn as_str(&self) -> &str {
49 &self.0
50 }
51}
52
53impl Display for RepoName {
54 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
55 f.write_str(&self.0)
56 }
57}
58
59#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
61#[serde(transparent)]
62pub struct ProjectName(String);
63
64impl ProjectName {
65 pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
67 let value = value.into();
68 validate_ascii_identifier("project name", &value, NAME_MAX_BYTES, true)?;
69 Ok(Self(value))
70 }
71
72 pub fn as_str(&self) -> &str {
74 &self.0
75 }
76}
77
78impl Display for ProjectName {
79 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
80 f.write_str(&self.0)
81 }
82}
83
84#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
86#[serde(transparent)]
87pub struct WorkspaceName(String);
88
89impl WorkspaceName {
90 pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
92 let value = value.into();
93 validate_ascii_identifier("workspace name", &value, NAME_MAX_BYTES, false)?;
94 Ok(Self(value))
95 }
96
97 pub fn as_str(&self) -> &str {
99 &self.0
100 }
101}
102
103impl Display for WorkspaceName {
104 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
105 f.write_str(&self.0)
106 }
107}
108
109#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
111#[serde(transparent)]
112pub struct OwnerHandle(String);
113
114impl OwnerHandle {
115 pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
117 let value = value.into();
118 if !value.starts_with('@') {
119 return Err(Diagnostic::error(
120 "manifest.owner.invalid",
121 "owner handle must start with @",
122 ));
123 }
124 validate_ascii_identifier("owner handle", &value[1..], OWNER_MAX_BYTES - 1, false)?;
125 Ok(Self(value))
126 }
127
128 pub fn as_str(&self) -> &str {
130 &self.0
131 }
132}
133
134impl Display for OwnerHandle {
135 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
136 f.write_str(&self.0)
137 }
138}
139
140#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
142#[serde(transparent)]
143pub struct TaskName(String);
144
145impl TaskName {
146 pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
148 let value = value.into();
149 validate_ascii_identifier("task name", &value, TASK_MAX_BYTES, false)?;
150 Ok(Self(value))
151 }
152
153 pub fn as_str(&self) -> &str {
155 &self.0
156 }
157}
158
159impl Display for TaskName {
160 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
161 f.write_str(&self.0)
162 }
163}
164
165#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
167#[serde(transparent)]
168pub struct SchemaId(String);
169
170impl SchemaId {
171 pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
173 let value = value.into();
174 if value.is_empty() || value.len() > SCHEMA_MAX_BYTES {
175 return Err(Diagnostic::error(
176 "manifest.schema.invalid",
177 "schema id must be non-empty and length-bounded",
178 ));
179 }
180 if !value
181 .bytes()
182 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'-' | b'_'))
183 {
184 return Err(Diagnostic::error(
185 "manifest.schema.invalid",
186 "schema id contains unsupported characters",
187 ));
188 }
189 Ok(Self(value))
190 }
191
192 pub fn as_str(&self) -> &str {
194 &self.0
195 }
196}
197
198impl Display for SchemaId {
199 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
200 f.write_str(&self.0)
201 }
202}
203
204#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
206#[serde(transparent)]
207pub struct ProtoPackageName(String);
208
209impl ProtoPackageName {
210 pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
212 let value = value.into();
213 validate_ascii_identifier("proto package", &value, NAME_MAX_BYTES, true)?;
214 Ok(Self(value))
215 }
216
217 pub fn as_str(&self) -> &str {
219 &self.0
220 }
221}
222
223impl Display for ProtoPackageName {
224 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
225 f.write_str(&self.0)
226 }
227}
228
229#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
231#[serde(transparent)]
232pub struct RepoRelativePath(String);
233
234impl RepoRelativePath {
235 pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
237 normalize_relative_path(value.into(), false).map(Self)
238 }
239
240 pub fn root() -> Self {
242 Self(".".to_string())
243 }
244
245 pub fn as_str(&self) -> &str {
247 &self.0
248 }
249
250 pub fn join_project(&self, child: &ProjectRelativePath) -> Result<Self, Diagnostic> {
252 if child.as_str() == "." {
253 return Ok(self.clone());
254 }
255 let joined = if self.0 == "." {
256 child.as_str().to_string()
257 } else {
258 format!("{}/{}", self.0, child.as_str())
259 };
260 Self::new(joined)
261 }
262
263 pub fn starts_with(&self, prefix: &RepoRelativePath) -> bool {
265 self.0 == prefix.0 || prefix.0 == "." || self.0.starts_with(&format!("{}/", prefix.0))
266 }
267}
268
269impl Display for RepoRelativePath {
270 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
271 f.write_str(&self.0)
272 }
273}
274
275impl Default for RepoRelativePath {
276 fn default() -> Self {
277 Self::root()
278 }
279}
280
281#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
283#[serde(transparent)]
284pub struct ProjectRelativePath(String);
285
286impl ProjectRelativePath {
287 pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
289 normalize_relative_path(value.into(), true).map(Self)
290 }
291
292 pub fn root() -> Self {
294 Self(".".to_string())
295 }
296
297 pub fn as_str(&self) -> &str {
299 &self.0
300 }
301}
302
303impl Display for ProjectRelativePath {
304 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
305 f.write_str(&self.0)
306 }
307}
308
309#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
311#[serde(transparent)]
312pub struct RepoGlob(String);
313
314impl RepoGlob {
315 pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
317 let value = value.into();
318 validate_path_text(&value, true)?;
319 Glob::new(&value).map_err(|source| {
320 Diagnostic::error(
321 "manifest.glob.invalid",
322 format!("invalid glob pattern `{value}`: {source}"),
323 )
324 })?;
325 Ok(Self(value))
326 }
327
328 pub fn as_str(&self) -> &str {
330 &self.0
331 }
332}
333
334impl Display for RepoGlob {
335 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
336 f.write_str(&self.0)
337 }
338}
339
340#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
342#[serde(rename_all = "camelCase")]
343pub struct RepoRoot {
344 pub absolute: Utf8PathBuf,
346}
347
348impl RepoRoot {
349 pub fn new(path: Utf8PathBuf) -> Result<Self, Diagnostic> {
351 if !Utf8Path::new(path.as_str()).is_absolute() {
352 return Err(Diagnostic::error(
353 "repo.root.relative",
354 "repo root must be an absolute path",
355 ));
356 }
357 Ok(Self { absolute: path })
358 }
359
360 pub fn join(&self, path: &RepoRelativePath) -> Utf8PathBuf {
362 if path.as_str() == "." {
363 self.absolute.clone()
364 } else {
365 self.absolute.join(path.as_str())
366 }
367 }
368}
369
370#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
372#[serde(rename_all = "kebab-case")]
373pub enum ProjectKind {
374 App,
376 Framework,
378 FoundationService,
380 ProtoRoot,
382 CoreInfra,
384 CoreInfraComponent,
386 Tool,
388}
389
390#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
392#[serde(rename_all = "kebab-case")]
393pub enum Toolchain {
394 Cargo,
396 Npm,
398 Pnpm,
400 Yarn,
402 Bun,
404 Uv,
406 Custom(String),
408}
409
410impl Toolchain {
411 pub fn parse(value: &str) -> Result<Self, Diagnostic> {
413 if value.is_empty() || value.len() > NAME_MAX_BYTES {
414 return Err(Diagnostic::error(
415 "manifest.workspace.toolchain",
416 "toolchain must be non-empty and length-bounded",
417 ));
418 }
419 if !value
420 .bytes()
421 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
422 {
423 return Err(Diagnostic::error(
424 "manifest.workspace.toolchain",
425 "toolchain contains unsupported characters",
426 ));
427 }
428 Ok(match value {
429 "cargo" => Self::Cargo,
430 "npm" => Self::Npm,
431 "pnpm" => Self::Pnpm,
432 "yarn" => Self::Yarn,
433 "bun" => Self::Bun,
434 "uv" => Self::Uv,
435 custom => Self::Custom(custom.to_string()),
436 })
437 }
438
439 pub fn as_str(&self) -> &str {
441 match self {
442 Self::Cargo => "cargo",
443 Self::Npm => "npm",
444 Self::Pnpm => "pnpm",
445 Self::Yarn => "yarn",
446 Self::Bun => "bun",
447 Self::Uv => "uv",
448 Self::Custom(value) => value.as_str(),
449 }
450 }
451}
452
453#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
455#[serde(rename_all = "kebab-case")]
456pub enum WorkspaceLanguage {
457 Rust,
459 #[serde(rename = "typescript")]
461 TypeScript,
462 Python,
464 Proto,
466 Iac,
468}
469
470#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
472#[serde(rename_all = "kebab-case")]
473pub enum Visibility {
474 #[default]
476 Internal,
477 Public,
479}
480
481#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
483#[serde(rename_all = "kebab-case")]
484pub enum IacProvider {
485 Pulumi,
487 Terraform,
489 #[serde(rename = "opentofu")]
491 OpenTofu,
492}
493
494#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
496#[serde(rename_all = "kebab-case")]
497pub enum GeneratedCodePolicy {
498 #[default]
500 ConsumerLocal,
501}
502
503#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
505#[serde(rename_all = "camelCase")]
506pub struct RepoManifest {
507 pub schema: SchemaId,
509 pub name: RepoName,
511 pub layout: RepoLayout,
513 pub default_owner: Option<OwnerHandle>,
515 pub protos_root: RepoRelativePath,
517 pub core_infra_root: RepoRelativePath,
519 pub agent_skills_root: RepoRelativePath,
521 pub claude_skills_root: RepoRelativePath,
523 pub context_output: RepoRelativePath,
525 pub generated_code_policy: GeneratedCodePolicy,
527 pub policies: RepoPolicySet,
529}
530
531#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
533#[serde(rename_all = "camelCase")]
534pub struct RepoPolicySet {
535 pub cross_app_dependency: PolicyMode,
537 pub framework_internal_dependency: PolicyMode,
539 pub generated_code_direct_edit: PolicyMode,
541 pub prod_change_required_owners: Vec<OwnerHandle>,
543}
544
545#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
547#[serde(rename_all = "kebab-case")]
548pub enum PolicyMode {
549 #[default]
551 Deny,
552 Warn,
554 Allow,
556}
557
558#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
560#[serde(rename_all = "camelCase")]
561pub struct WorkspaceSpec {
562 pub name: WorkspaceName,
564 pub language: WorkspaceLanguage,
566 pub toolchain: Option<Toolchain>,
568 pub root: ProjectRelativePath,
570 pub manifest: ProjectRelativePath,
572 pub lockfile: Option<ProjectRelativePath>,
574 pub target_dir: Option<RepoRelativePath>,
576 pub cache_dir: Option<RepoRelativePath>,
578}
579
580#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
582#[serde(rename_all = "camelCase")]
583pub struct TaskCommand {
584 pub workspace: WorkspaceName,
586 pub command: CommandSpec,
588 pub depends_on: Vec<TaskDependency>,
590}
591
592#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
594#[serde(rename_all = "camelCase")]
595pub struct TaskDependency {
596 pub project: ProjectName,
598 pub workspace: WorkspaceName,
600 pub task: TaskName,
602}
603
604#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
606#[serde(rename_all = "camelCase")]
607pub struct CommandSpec {
608 pub program: String,
610 pub args: Vec<String>,
612}
613
614impl CommandSpec {
615 pub fn parse(value: &str) -> Result<Self, Diagnostic> {
617 if value.trim().is_empty() || value.len() > COMMAND_PART_MAX_BYTES * COMMAND_ARG_LIMIT {
618 return Err(Diagnostic::error(
619 "manifest.command.invalid",
620 "command must be non-empty and length-bounded",
621 ));
622 }
623 reject_shell_syntax(value)?;
624 let parts = split_command(value)?;
625 if parts.is_empty() {
626 return Err(Diagnostic::error(
627 "manifest.command.invalid",
628 "command must include a program",
629 ));
630 }
631 if parts.len() > COMMAND_ARG_LIMIT {
632 return Err(Diagnostic::error(
633 "manifest.command.too_many_args",
634 "command has too many arguments",
635 ));
636 }
637 for part in &parts {
638 if part.len() > COMMAND_PART_MAX_BYTES {
639 return Err(Diagnostic::error(
640 "manifest.command.part_too_long",
641 "command part exceeds byte limit",
642 ));
643 }
644 }
645 let mut parts = parts.into_iter();
646 let program = parts.next().ok_or_else(|| {
647 Diagnostic::error("manifest.command.invalid", "command must include a program")
648 })?;
649 Ok(Self {
650 program,
651 args: parts.collect(),
652 })
653 }
654}
655
656#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
658#[serde(rename_all = "kebab-case")]
659pub enum DependencySurface {
660 Unspecified,
662 FrameworkFacade,
664 FrameworkInternal,
666 FoundationPublicClient,
668 FoundationInternal,
670 CoreInfraPublicModule,
672 CoreInfraInternalModule,
674}
675
676#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
678#[serde(rename_all = "camelCase")]
679pub enum DependencyTarget {
680 Project(ProjectName),
682 ProtoPackage(ProtoPackageName),
684}
685
686#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
688#[serde(rename_all = "camelCase")]
689pub struct ProjectDependency {
690 pub id: String,
692 pub target: DependencyTarget,
694 pub surface: DependencySurface,
696}
697
698impl ProjectDependency {
699 pub fn parse(value: impl Into<String>) -> Result<Self, Diagnostic> {
701 let value = value.into();
702 if value.starts_with("protos.") {
703 return Ok(Self {
704 target: DependencyTarget::ProtoPackage(ProtoPackageName::new(value.clone())?),
705 id: value,
706 surface: DependencySurface::Unspecified,
707 });
708 }
709 let (target, surface) = if let Some(stripped) = value.strip_suffix(".client") {
710 (
711 ProjectName::new(stripped.to_string())?,
712 DependencySurface::FoundationPublicClient,
713 )
714 } else if let Some(stripped) = value.strip_suffix(".internal") {
715 let surface = if stripped.starts_with("frameworks.") {
716 DependencySurface::FrameworkInternal
717 } else if stripped.starts_with("foundations.") {
718 DependencySurface::FoundationInternal
719 } else {
720 DependencySurface::Unspecified
721 };
722 (ProjectName::new(stripped.to_string())?, surface)
723 } else if let Some(stripped) = value.strip_suffix(".facade") {
724 (
725 ProjectName::new(stripped.to_string())?,
726 DependencySurface::FrameworkFacade,
727 )
728 } else {
729 (
730 ProjectName::new(value.clone())?,
731 DependencySurface::Unspecified,
732 )
733 };
734 Ok(Self {
735 id: value,
736 target: DependencyTarget::Project(target),
737 surface,
738 })
739 }
740}
741
742#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
744#[serde(rename_all = "camelCase")]
745pub struct ProjectProtoSpec {
746 pub owns: Vec<RepoGlob>,
748 pub consumes: Vec<RepoGlob>,
750}
751
752#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
754#[serde(rename_all = "camelCase")]
755pub struct IacSpec {
756 pub root: ProjectRelativePath,
758 pub provider: IacProvider,
760 pub stacks: Vec<String>,
762}
763
764#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
766#[serde(rename_all = "camelCase")]
767pub struct DeploySpec {
768 pub root: ProjectRelativePath,
770 pub environments: Vec<String>,
772}
773
774#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
776#[serde(rename_all = "camelCase")]
777pub struct ProjectDnsSpec {
778 #[serde(skip_serializing_if = "Option::is_none")]
780 pub provider: Option<String>,
781 pub records: Vec<DnsRecordSpec>,
783}
784
785#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
787#[serde(rename_all = "camelCase")]
788pub struct DnsRecordSpec {
789 pub name: String,
791 pub record_type: String,
793 pub target: String,
795 #[serde(skip_serializing_if = "Option::is_none")]
797 pub proxied: Option<bool>,
798 #[serde(skip_serializing_if = "Option::is_none")]
800 pub ttl: Option<u32>,
801}
802
803#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
805#[serde(rename_all = "camelCase")]
806pub struct CdnSpec {
807 pub provider: String,
809 pub aliases: Vec<String>,
811 pub expected_response_headers: Vec<String>,
813}
814
815#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
817#[serde(rename_all = "camelCase")]
818pub struct ProjectOpsSpec {
819 pub probes: Vec<ProbeSpec>,
821 pub runtime_dependencies: Vec<RuntimeDependencySpec>,
823 pub manual_state: Vec<ManualStateRecord>,
825}
826
827#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
829#[serde(rename_all = "camelCase")]
830pub struct ProbeSpec {
831 pub name: String,
833 pub method: String,
835 pub url: String,
837 pub expect: ProbeExpectation,
839 #[serde(skip_serializing_if = "Option::is_none")]
841 pub classification: Option<String>,
842}
843
844#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
846#[serde(rename_all = "camelCase")]
847pub struct ProbeExpectation {
848 #[serde(skip_serializing_if = "Option::is_none")]
850 pub status: Option<u16>,
851 pub headers: BTreeMap<String, String>,
853 #[serde(skip_serializing_if = "Option::is_none")]
855 pub body_contains: Option<String>,
856}
857
858#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
860#[serde(rename_all = "camelCase")]
861pub struct RuntimeDependencySpec {
862 pub project: ProjectName,
864 pub endpoint: String,
866 pub purpose: String,
868}
869
870#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
872#[serde(rename_all = "camelCase")]
873pub struct ManualStateRecord {
874 pub kind: String,
876 pub resource: String,
878 pub status: String,
880 #[serde(skip_serializing_if = "Option::is_none")]
882 pub managed_equivalent: Option<String>,
883 #[serde(skip_serializing_if = "Option::is_none")]
885 pub cleanup_command: Option<ProcessCommand>,
886}
887
888#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
890#[serde(rename_all = "camelCase")]
891pub struct ProjectAiSpec {
892 pub editable: Vec<RepoGlob>,
894 pub do_not_edit: Vec<RepoGlob>,
896 pub docs: Vec<ProjectRelativePath>,
898}
899
900#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
902#[serde(rename_all = "camelCase")]
903pub struct ProjectAreas {
904 pub public_facades: BTreeMap<String, Vec<ProjectRelativePath>>,
906 pub public_clients: BTreeMap<String, Vec<ProjectRelativePath>>,
908 pub internal: BTreeMap<String, Vec<ProjectRelativePath>>,
910 pub public_modules: Vec<ProjectRelativePath>,
912 pub internal_modules: Vec<ProjectRelativePath>,
914}
915
916#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
918#[serde(rename_all = "camelCase")]
919pub struct ProjectManifest {
920 pub schema: SchemaId,
922 pub name: ProjectName,
924 pub kind: ProjectKind,
926 pub path: RepoRelativePath,
928 pub owners: Vec<OwnerHandle>,
930 pub visibility: Visibility,
932 pub workspaces: Vec<WorkspaceSpec>,
934 pub depends_on: Vec<ProjectDependency>,
936 pub tasks: BTreeMap<TaskName, Vec<TaskCommand>>,
938 pub iac: Option<IacSpec>,
940 pub deploy: Option<DeploySpec>,
942 pub dns: ProjectDnsSpec,
944 pub cdn: Option<CdnSpec>,
946 pub ops: ProjectOpsSpec,
948 pub protos: ProjectProtoSpec,
950 pub ai: ProjectAiSpec,
952 pub areas: ProjectAreas,
954 pub policies: BTreeMap<String, serde_json::Value>,
956 pub source: RepoRelativePath,
958}
959
960impl ProjectManifest {
961 pub fn node_id(&self) -> String {
963 format!("project:{}", self.name)
964 }
965
966 pub fn contains_path(&self, path: &RepoRelativePath) -> bool {
968 path.starts_with(&self.path)
969 }
970}
971
972#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
974#[serde(rename_all = "camelCase")]
975pub struct TemplateManifest {
976 pub schema: SchemaId,
978 pub name: String,
980 pub kind: String,
982 pub engine: String,
984 pub inputs: Vec<TemplateInput>,
986 pub files: Vec<TemplateFile>,
988 pub post_render_validate: Vec<CommandSpec>,
990}
991
992#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
994#[serde(rename_all = "camelCase", tag = "kind")]
995pub enum TemplateSource {
996 Builtin {
998 name: String,
1000 },
1001 Local {
1003 root: RepoRelativePath,
1005 },
1006}
1007
1008#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1010#[serde(rename_all = "camelCase")]
1011pub struct ResolvedTemplateSource {
1012 pub root: RepoRelativePath,
1014 pub manifest: TemplateManifest,
1016}
1017
1018#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1020#[serde(rename_all = "camelCase")]
1021pub struct TemplateInput {
1022 pub name: String,
1024 pub input_type: String,
1026 pub required: bool,
1028 pub default: Option<serde_json::Value>,
1030}
1031
1032#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1034#[serde(rename_all = "camelCase")]
1035pub struct TemplateFile {
1036 pub source: ProjectRelativePath,
1038 pub target: String,
1040 pub mode: String,
1042 pub when: Option<String>,
1044}
1045
1046#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1048#[serde(rename_all = "kebab-case")]
1049pub enum GraphNodeKind {
1050 Project,
1052 Workspace,
1054 ProtoPackage,
1056 IacTarget,
1058 Template,
1060}
1061
1062#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1064#[serde(rename_all = "camelCase")]
1065pub struct GraphNode {
1066 pub id: String,
1068 pub kind: GraphNodeKind,
1070 pub label: String,
1072 pub project: Option<ProjectName>,
1074 pub workspace: Option<WorkspaceName>,
1076}
1077
1078#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1080#[serde(rename_all = "kebab-case")]
1081pub enum EdgeKind {
1082 DependsOnProject,
1084 ContainsWorkspace,
1086 ConsumesProto,
1088 OwnsProto,
1090 UsesFrameworkFacade,
1092 UsesFrameworkInternal,
1094 UsesFoundationClient,
1096 UsesFoundationInternal,
1098 UsesCoreInfraModule,
1100 UsesCoreInfraInternalModule,
1102 OwnsIac,
1104 RunsTask,
1106}
1107
1108#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1110#[serde(rename_all = "camelCase")]
1111pub struct GraphEdge {
1112 pub from: String,
1114 pub to: String,
1116 pub kind: EdgeKind,
1118 pub evidence: Option<String>,
1120}
1121
1122#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1124#[serde(rename_all = "camelCase")]
1125pub struct RepoGraph {
1126 pub nodes: Vec<GraphNode>,
1128 pub edges: Vec<GraphEdge>,
1130}
1131
1132impl RepoGraph {
1133 pub fn add_node(&mut self, node: GraphNode) {
1135 if !self.nodes.iter().any(|existing| existing.id == node.id) {
1136 self.nodes.push(node);
1137 }
1138 }
1139
1140 pub fn add_edge(&mut self, edge: GraphEdge) {
1142 if !self.edges.iter().any(|existing| existing == &edge) {
1143 self.edges.push(edge);
1144 }
1145 }
1146}
1147
1148#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1150#[serde(rename_all = "camelCase")]
1151pub struct RepoSnapshot {
1152 pub root: RepoRoot,
1154 pub repo_manifest: RepoManifest,
1156 pub projects: Vec<ProjectManifest>,
1158 pub graph: RepoGraph,
1160 pub generated_policy: GeneratedCodePolicy,
1162 pub discovered_at_unix: u64,
1164}
1165
1166impl RepoSnapshot {
1167 pub fn new(
1169 root: RepoRoot,
1170 repo_manifest: RepoManifest,
1171 projects: Vec<ProjectManifest>,
1172 graph: RepoGraph,
1173 ) -> Self {
1174 let discovered_at_unix = SystemTime::now()
1175 .duration_since(UNIX_EPOCH)
1176 .map_or(0, |duration| duration.as_secs());
1177 let generated_policy = repo_manifest.generated_code_policy.clone();
1178 Self {
1179 root,
1180 repo_manifest,
1181 projects,
1182 graph,
1183 generated_policy,
1184 discovered_at_unix,
1185 }
1186 }
1187
1188 pub fn project(&self, name: &ProjectName) -> Option<&ProjectManifest> {
1190 self.projects.iter().find(|project| &project.name == name)
1191 }
1192}
1193
1194#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1196#[serde(rename_all = "camelCase")]
1197pub struct DiscoverRequest {
1198 pub repo: Option<PathBuf>,
1200}
1201
1202#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1204#[serde(rename_all = "camelCase")]
1205pub struct GraphValidateRequest {
1206 pub repo: Option<PathBuf>,
1208 pub changed_files: Vec<RepoRelativePath>,
1210 pub mode: ValidationMode,
1212}
1213
1214#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1216#[serde(rename_all = "kebab-case")]
1217pub enum ValidationMode {
1218 #[default]
1220 Structural,
1221 Metadata,
1223 Full,
1225}
1226
1227#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1229#[serde(rename_all = "camelCase")]
1230pub struct GraphPrintRequest {
1231 pub repo: Option<PathBuf>,
1233}
1234
1235#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1237#[serde(rename_all = "camelCase")]
1238pub struct GraphPrintReport {
1239 pub snapshot: RepoSnapshot,
1241}
1242
1243#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1245#[serde(rename_all = "camelCase")]
1246pub struct ExplainRequest {
1247 pub repo: Option<PathBuf>,
1249 pub selector: String,
1251}
1252
1253#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1255#[serde(rename_all = "camelCase")]
1256pub struct ExplainReport {
1257 pub selector: String,
1259 pub nodes: Vec<GraphNode>,
1261 pub edges: Vec<GraphEdge>,
1263 pub diagnostics: Vec<Diagnostic>,
1265}
1266
1267#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1269#[serde(rename_all = "camelCase")]
1270pub struct BoundaryLintRequest {
1271 pub repo: Option<PathBuf>,
1273 pub changed_files: Vec<RepoRelativePath>,
1275}
1276
1277#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1279#[serde(rename_all = "camelCase")]
1280pub struct BoundaryLintReport {
1281 pub diagnostics: Vec<Diagnostic>,
1283}
1284
1285#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1287#[serde(rename_all = "kebab-case")]
1288pub enum InitProfile {
1289 Startup,
1291 Enterprise,
1293}
1294
1295#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1297#[serde(rename_all = "camelCase")]
1298pub struct InitRequest {
1299 pub repo_root: PathBuf,
1301 pub name: RepoName,
1303 pub profile: InitProfile,
1305 pub layout: RepoLayout,
1307 pub dry_run: bool,
1309}
1310
1311#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1313#[serde(rename_all = "camelCase")]
1314pub struct FileOperation {
1315 pub path: RepoRelativePath,
1317 pub operation: String,
1319 #[serde(skip_serializing_if = "Option::is_none")]
1321 pub content: Option<String>,
1322}
1323
1324#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1326#[serde(rename_all = "camelCase")]
1327pub struct InitPlan {
1328 pub operations: Vec<FileOperation>,
1330 pub warnings: Vec<Diagnostic>,
1332 pub next_steps: Vec<String>,
1334}
1335
1336#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1338#[serde(rename_all = "camelCase")]
1339pub struct NewProjectRequest {
1340 pub repo: Option<PathBuf>,
1342 pub kind: ProjectKind,
1344 pub path: RepoRelativePath,
1346 pub stack: Vec<String>,
1348 pub languages: Vec<String>,
1350 pub clients: Vec<String>,
1352 pub facade: bool,
1354 pub iac: Option<IacProvider>,
1356 pub proto: Option<ProtoPackageName>,
1358 pub owner: Option<OwnerHandle>,
1360 pub dry_run: bool,
1362}
1363
1364#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1366#[serde(rename_all = "camelCase")]
1367pub struct RenderPlan {
1368 pub operations: Vec<FileOperation>,
1370 pub diagnostics: Vec<Diagnostic>,
1372}
1373
1374#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1376#[serde(rename_all = "camelCase")]
1377pub struct TemplateListRequest {
1378 pub repo: Option<PathBuf>,
1380}
1381
1382#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1384#[serde(rename_all = "camelCase")]
1385pub struct TemplateSummary {
1386 pub source: String,
1388 pub name: String,
1390 pub kind: String,
1392}
1393
1394#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1396#[serde(rename_all = "camelCase")]
1397pub struct TemplateListReport {
1398 pub templates: Vec<TemplateSummary>,
1400 pub diagnostics: Vec<Diagnostic>,
1402}
1403
1404#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1406#[serde(rename_all = "camelCase")]
1407pub struct TemplateRenderRequest {
1408 pub repo: Option<PathBuf>,
1410 pub source: TemplateSource,
1412 pub inputs: serde_json::Value,
1414 pub dry_run: bool,
1416}
1417
1418#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1420#[serde(rename_all = "camelCase")]
1421pub struct AffectedRequest {
1422 pub repo: Option<PathBuf>,
1424 pub base: Option<String>,
1426 pub head: Option<String>,
1428 pub changed_files: Vec<RepoRelativePath>,
1430 pub tasks: Vec<TaskName>,
1432}
1433
1434#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1436#[serde(rename_all = "camelCase")]
1437pub struct AffectedReport {
1438 pub directly_affected: Vec<ProjectName>,
1440 pub transitively_affected: Vec<ProjectName>,
1442 pub workspaces: Vec<String>,
1444 pub tasks: Vec<String>,
1446 pub risk_flags: Vec<String>,
1448 pub reasons: Vec<AffectedReason>,
1450 pub suggested_reviewers: Vec<OwnerHandle>,
1452 pub diagnostics: Vec<Diagnostic>,
1454}
1455
1456#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1458#[serde(rename_all = "camelCase")]
1459pub struct AffectedReason {
1460 pub source: String,
1462 pub target: String,
1464 pub reason: String,
1466}
1467
1468#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1470#[serde(rename_all = "camelCase")]
1471pub struct TaskRunRequest {
1472 pub repo: Option<PathBuf>,
1474 pub tasks: Vec<TaskName>,
1476 pub projects: Vec<ProjectName>,
1478 pub workspaces: Vec<String>,
1480 pub affected: bool,
1482 pub changed_files: Vec<RepoRelativePath>,
1484 pub base: Option<String>,
1486 pub head: Option<String>,
1488 pub concurrency: Option<NonZeroU32>,
1490 pub dry_run: bool,
1492}
1493
1494#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1496#[serde(rename_all = "camelCase")]
1497pub struct TaskRunPlan {
1498 pub commands: Vec<ProcessCommand>,
1500 pub concurrency: NonZeroUsize,
1502}
1503
1504#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1506#[serde(rename_all = "camelCase")]
1507pub struct TaskRunReport {
1508 pub commands: Vec<ProcessCommand>,
1510 pub outputs: Vec<TaskCommandOutput>,
1512 pub diagnostics: Vec<Diagnostic>,
1514}
1515
1516#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1518#[serde(rename_all = "camelCase")]
1519pub struct TaskCommandOutput {
1520 pub project: ProjectName,
1522 pub workspace: WorkspaceName,
1524 pub task: TaskName,
1526 pub output: ProcessOutput,
1528}
1529
1530#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1532#[serde(rename_all = "camelCase")]
1533pub struct ProcessCommand {
1534 #[serde(skip_serializing_if = "Option::is_none")]
1536 pub project: Option<ProjectName>,
1537 #[serde(skip_serializing_if = "Option::is_none")]
1539 pub workspace: Option<WorkspaceName>,
1540 #[serde(skip_serializing_if = "Option::is_none")]
1542 pub task: Option<TaskName>,
1543 pub cwd: RepoRelativePath,
1545 #[serde(skip_serializing_if = "Option::is_none")]
1547 pub absolute_cwd: Option<PathBuf>,
1548 pub program: String,
1550 pub args: Vec<String>,
1552 pub env: BTreeMap<String, String>,
1554}
1555
1556#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1558#[serde(rename_all = "camelCase")]
1559pub struct ProcessOutput {
1560 pub status: i32,
1562 pub stdout: String,
1564 pub stderr: String,
1566}
1567
1568#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1570#[serde(rename_all = "camelCase")]
1571pub struct CiMatrixRequest {
1572 pub repo: Option<PathBuf>,
1574 pub tasks: Vec<TaskName>,
1576 pub changed_files: Vec<RepoRelativePath>,
1578 pub base: Option<String>,
1580 pub head: Option<String>,
1582 pub fallback: CiFallback,
1584}
1585
1586#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1588#[serde(rename_all = "kebab-case")]
1589pub enum CiFallback {
1590 All,
1592 #[default]
1594 None,
1595 Error,
1597}
1598
1599#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1601#[serde(rename_all = "camelCase")]
1602pub struct CiMatrixReport {
1603 pub entries: Vec<serde_json::Value>,
1605 pub github_actions: serde_json::Value,
1607}
1608
1609#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1611#[serde(rename_all = "kebab-case")]
1612pub enum CiProvider {
1613 #[default]
1615 GitHubActions,
1616}
1617
1618#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1620#[serde(rename_all = "camelCase")]
1621pub struct CiWorkflowRequest {
1622 pub repo: Option<PathBuf>,
1624 pub provider: CiProvider,
1626 pub write: bool,
1628}
1629
1630#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1632#[serde(rename_all = "camelCase")]
1633pub struct CiWorkflowReport {
1634 pub path: RepoRelativePath,
1636 pub content: String,
1638 pub operations: Vec<FileOperation>,
1640 pub diagnostics: Vec<Diagnostic>,
1642}
1643
1644#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1646#[serde(rename_all = "camelCase")]
1647pub struct HygieneCheckRequest {
1648 pub repo: Option<PathBuf>,
1650}
1651
1652#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1654#[serde(rename_all = "camelCase")]
1655pub struct HygieneCleanRequest {
1656 pub repo: Option<PathBuf>,
1658 pub dry_run: bool,
1660}
1661
1662#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1664#[serde(rename_all = "camelCase")]
1665pub struct HygieneReport {
1666 pub diagnostics: Vec<Diagnostic>,
1668 pub operations: Vec<FileOperation>,
1670}
1671
1672#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1674#[serde(rename_all = "kebab-case")]
1675pub enum DependencyRewriteMode {
1676 #[default]
1678 Auto,
1679 Off,
1681 ReportOnly,
1683}
1684
1685#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1687#[serde(rename_all = "kebab-case")]
1688pub enum AdoptionCiMode {
1689 #[default]
1691 Update,
1692 Off,
1694 ReportOnly,
1696}
1697
1698#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
1700#[serde(rename_all = "kebab-case")]
1701pub enum AdoptionOutputFormat {
1702 #[default]
1704 Human,
1705 Json,
1707 GitHubActions,
1709}
1710
1711#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1713#[serde(rename_all = "camelCase")]
1714pub struct AdoptionPlanRequest {
1715 pub source: PathBuf,
1717 pub dest: PathBuf,
1719 pub include: Vec<String>,
1721 pub exclude: Vec<String>,
1723 pub map: BTreeMap<String, RepoRelativePath>,
1725 pub kind: BTreeMap<String, ProjectKind>,
1727 pub owner: BTreeMap<String, OwnerHandle>,
1729 pub rewrite_deps: DependencyRewriteMode,
1731 pub ci: AdoptionCiMode,
1733 pub verification: ValidationMode,
1735 pub format: AdoptionOutputFormat,
1737}
1738
1739#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1741#[serde(rename_all = "camelCase")]
1742pub struct AdoptionApplyRequest {
1743 pub plan: PathBuf,
1745 pub refresh: bool,
1747}
1748
1749#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1751#[serde(rename_all = "camelCase")]
1752pub struct AdoptionVerifyRequest {
1753 pub plan: PathBuf,
1755}
1756
1757#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
1759#[serde(rename_all = "camelCase")]
1760pub struct AdoptionPlan {
1761 pub source_root: Utf8PathBuf,
1763 pub dest_root: Utf8PathBuf,
1765 pub sources: Vec<AdoptedSource>,
1767 pub operations: Vec<AdoptionFileOperation>,
1769 pub dependency_rewrites: Vec<DependencyRewrite>,
1771 pub manifest_syntheses: Vec<ProjectManifestSynthesis>,
1773 pub ci_operations: Vec<FileOperation>,
1775 pub verification: VerificationPlan,
1777 pub diagnostics: Vec<Diagnostic>,
1779}
1780
1781#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1783#[serde(rename_all = "camelCase")]
1784pub struct AdoptedSource {
1785 pub name: String,
1787 pub source_path: Utf8PathBuf,
1789 pub destination_path: RepoRelativePath,
1791 pub inferred_kind: ProjectKind,
1793 pub confidence: f32,
1795 pub reasons: Vec<String>,
1797 pub inventory: SourceInventory,
1799 pub skipped: bool,
1801 pub override_applied: bool,
1803}
1804
1805#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1807#[serde(rename_all = "camelCase")]
1808pub struct SourceInventory {
1809 pub name: String,
1811 pub has_vcs: bool,
1813 pub readme_summary: Option<String>,
1815 pub manifests: Vec<String>,
1817 pub top_level_dirs: Vec<String>,
1819 pub dependency_references: Vec<String>,
1821 pub generated_artifacts: Vec<String>,
1823 pub required_tools: Vec<String>,
1825}
1826
1827#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1829#[serde(rename_all = "camelCase")]
1830pub struct AdoptionFileOperation {
1831 pub id: String,
1833 pub operation: String,
1835 #[serde(skip_serializing_if = "Option::is_none")]
1837 pub source_path: Option<Utf8PathBuf>,
1838 pub destination_path: RepoRelativePath,
1840 #[serde(skip_serializing_if = "Option::is_none")]
1842 pub content: Option<String>,
1843 #[serde(skip_serializing_if = "Option::is_none")]
1845 pub checksum: Option<String>,
1846}
1847
1848#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1850#[serde(rename_all = "camelCase")]
1851pub struct DependencyRewrite {
1852 pub file: RepoRelativePath,
1854 pub package: String,
1856 pub from: String,
1858 pub to: String,
1860 pub surface: DependencySurface,
1862 pub owner_project: ProjectName,
1864}
1865
1866#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1868#[serde(rename_all = "camelCase")]
1869pub struct ProjectManifestSynthesis {
1870 pub source: String,
1872 pub project: ProjectName,
1874 pub manifest_path: RepoRelativePath,
1876 pub content: String,
1878}
1879
1880#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1882#[serde(rename_all = "camelCase")]
1883pub struct VerificationPlan {
1884 pub prerequisites: Vec<ToolPrerequisite>,
1886 pub commands: Vec<ProcessCommand>,
1888}
1889
1890#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1892#[serde(rename_all = "camelCase")]
1893pub struct ToolPrerequisite {
1894 pub tool: String,
1896 pub reason: String,
1898}
1899
1900#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1902#[serde(rename_all = "camelCase")]
1903pub struct PrSummaryRequest {
1904 pub repo: Option<PathBuf>,
1906 pub base: Option<String>,
1908 pub head: Option<String>,
1910 pub changed_files: Vec<RepoRelativePath>,
1912}
1913
1914#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1916#[serde(rename_all = "camelCase")]
1917pub struct PrSummary {
1918 pub markdown: String,
1920 pub impact: serde_json::Value,
1922}
1923
1924#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1926#[serde(rename_all = "camelCase")]
1927pub struct AiContextRequest {
1928 pub repo: Option<PathBuf>,
1930 pub project: ProjectName,
1932 pub audience: String,
1934}
1935
1936#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1938#[serde(rename_all = "camelCase")]
1939pub struct AiContext {
1940 pub payload: serde_json::Value,
1942}
1943
1944#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1946#[serde(rename_all = "camelCase")]
1947pub struct CodegenCheckRequest {
1948 pub repo: Option<PathBuf>,
1950 pub base: Option<String>,
1952 pub head: Option<String>,
1954 pub changed_files: Vec<RepoRelativePath>,
1956}
1957
1958#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1960#[serde(rename_all = "camelCase")]
1961pub struct CodegenCheckReport {
1962 pub diagnostics: Vec<Diagnostic>,
1964}
1965
1966#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1968#[serde(rename_all = "camelCase")]
1969pub struct ProtoFacadeRequest {
1970 pub repo: Option<PathBuf>,
1972 pub operation: ProtoOperation,
1974 #[serde(skip_serializing_if = "Option::is_none")]
1976 pub selector: Option<String>,
1977 pub base: Option<String>,
1979 pub head: Option<String>,
1981 pub changed_files: Vec<RepoRelativePath>,
1983}
1984
1985#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1987#[serde(rename_all = "kebab-case")]
1988pub enum ProtoOperation {
1989 #[default]
1991 Check,
1992 Owners,
1994 Consumers,
1996}
1997
1998#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2000#[serde(rename_all = "camelCase")]
2001pub struct ProtoFacadeReport {
2002 pub owners: Vec<ProjectName>,
2004 pub consumers: Vec<ProjectName>,
2006 pub commands: Vec<ProcessCommand>,
2008 pub diagnostics: Vec<Diagnostic>,
2010}
2011
2012#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2014#[serde(rename_all = "camelCase")]
2015pub struct IacFacadeRequest {
2016 pub repo: Option<PathBuf>,
2018 pub affected: bool,
2020 #[serde(skip_serializing_if = "Option::is_none")]
2022 pub project: Option<ProjectName>,
2023 #[serde(skip_serializing_if = "Option::is_none")]
2025 pub env: Option<String>,
2026 pub core: bool,
2028 pub base: Option<String>,
2030 pub head: Option<String>,
2032 pub changed_files: Vec<RepoRelativePath>,
2034 pub dry_run: bool,
2036}
2037
2038#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2040#[serde(rename_all = "camelCase")]
2041pub struct IacFacadeReport {
2042 pub commands: Vec<ProcessCommand>,
2044 pub risk_flags: Vec<String>,
2046 pub diagnostics: Vec<Diagnostic>,
2048}
2049
2050#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2052#[serde(rename_all = "camelCase")]
2053pub struct OpsPlanRequest {
2054 pub repo: Option<PathBuf>,
2056 pub base: Option<String>,
2058 pub head: Option<String>,
2060 pub changed_files: Vec<RepoRelativePath>,
2062 pub environments: Vec<String>,
2064 pub tasks: Vec<TaskName>,
2066 #[serde(skip_serializing_if = "Option::is_none")]
2068 pub output: Option<PathBuf>,
2069}
2070
2071#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2073#[serde(rename_all = "camelCase")]
2074pub struct OpsPlan {
2075 pub id: String,
2077 #[serde(skip_serializing_if = "Option::is_none")]
2079 pub repo_root: Option<RepoRoot>,
2080 #[serde(skip_serializing_if = "Option::is_none")]
2082 pub base: Option<String>,
2083 #[serde(skip_serializing_if = "Option::is_none")]
2085 pub head: Option<String>,
2086 pub environments: Vec<String>,
2088 pub affected: AffectedReport,
2090 pub task_plan: TaskRunReport,
2092 pub iac: Vec<IacOperation>,
2094 pub dns: Vec<DnsOperation>,
2096 pub cdn: Vec<CdnCheck>,
2098 pub provider_capabilities: Vec<ProviderCapabilityReport>,
2100 pub probes: Vec<ProbeSpec>,
2102 pub manual_reconciliation: Vec<ManualStateRecord>,
2104 pub required_env: Vec<String>,
2106 pub production_gaps: Vec<String>,
2108 pub diagnostics: Vec<Diagnostic>,
2110}
2111
2112#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2114#[serde(rename_all = "camelCase")]
2115pub struct IacOperation {
2116 #[serde(skip_serializing_if = "Option::is_none")]
2118 pub project: Option<ProjectName>,
2119 pub workspace: String,
2121 pub provider: IacProvider,
2123 pub environment: String,
2125 pub stack: String,
2127 pub preview_command: ProcessCommand,
2129 #[serde(skip_serializing_if = "Option::is_none")]
2131 pub apply_command: Option<ProcessCommand>,
2132 pub risk: Vec<String>,
2134}
2135
2136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2138#[serde(rename_all = "camelCase")]
2139pub struct DnsOperation {
2140 pub zone: String,
2142 pub provider: String,
2144 pub record: String,
2146 pub expected_target: String,
2148 #[serde(skip_serializing_if = "Option::is_none")]
2150 pub expected_proxied: Option<bool>,
2151 pub verification: Vec<ProcessCommand>,
2153}
2154
2155#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2157#[serde(rename_all = "camelCase")]
2158pub struct CdnCheck {
2159 pub provider: String,
2161 pub alias: String,
2163 pub expected_response_headers: Vec<String>,
2165 pub verification: ProcessCommand,
2167}
2168
2169#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2171#[serde(rename_all = "camelCase")]
2172pub struct OpsVerifyRequest {
2173 pub repo: Option<PathBuf>,
2175 pub plan: PathBuf,
2177}
2178
2179#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2181#[serde(rename_all = "camelCase")]
2182pub struct OpsVerifyReport {
2183 pub commands: Vec<ProcessCommand>,
2185 pub skipped_mutating_commands: Vec<ProcessCommand>,
2187 pub diagnostics: Vec<Diagnostic>,
2189}
2190
2191#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2193#[serde(rename_all = "camelCase")]
2194pub struct OpsReconcileRequest {
2195 pub repo: Option<PathBuf>,
2197 pub plan: PathBuf,
2199}
2200
2201#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2203#[serde(rename_all = "camelCase")]
2204pub struct OpsReconcileReport {
2205 pub records: Vec<ManualStateRecord>,
2207 pub cleanup_commands: Vec<ProcessCommand>,
2209 pub diagnostics: Vec<Diagnostic>,
2211}
2212
2213#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2215#[serde(rename_all = "camelCase")]
2216pub struct ProviderCapabilityRequest {
2217 pub repo: Option<PathBuf>,
2219 #[serde(skip_serializing_if = "Option::is_none")]
2221 pub workspace: Option<String>,
2222 pub base: Option<String>,
2224 pub head: Option<String>,
2226 pub changed_files: Vec<RepoRelativePath>,
2228}
2229
2230#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2232#[serde(rename_all = "camelCase")]
2233pub struct ProviderCapabilityReport {
2234 pub workspace: String,
2236 pub package: String,
2238 pub version: String,
2240 pub resource: String,
2242 pub field: String,
2244 pub status: String,
2246 pub advice: String,
2248 pub diagnostics: Vec<Diagnostic>,
2250}
2251
2252#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2254#[serde(rename_all = "camelCase")]
2255pub struct SessionJournal {
2256 pub id: String,
2258 pub name: String,
2260 #[serde(skip_serializing_if = "Option::is_none")]
2262 pub plan_id: Option<String>,
2263 pub entries: Vec<SessionEntry>,
2265}
2266
2267#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2269#[serde(rename_all = "camelCase")]
2270pub struct SessionEntry {
2271 pub kind: String,
2273 pub timestamp: u64,
2275 #[serde(skip_serializing_if = "Option::is_none")]
2277 pub command: Option<String>,
2278 #[serde(skip_serializing_if = "Option::is_none")]
2280 pub exit_status: Option<i32>,
2281 #[serde(skip_serializing_if = "Option::is_none")]
2283 pub message: Option<String>,
2284 #[serde(skip_serializing_if = "Option::is_none")]
2286 pub plan_id: Option<String>,
2287}
2288
2289#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2291#[serde(rename_all = "camelCase")]
2292pub struct OpsJournalRequest {
2293 pub repo: Option<PathBuf>,
2295 pub action: OpsJournalAction,
2297}
2298
2299#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2301#[serde(rename_all = "camelCase", tag = "kind")]
2302pub enum OpsJournalAction {
2303 Start {
2305 name: String,
2307 plan_id: Option<String>,
2309 },
2310 AddCommand {
2312 session: String,
2314 command: String,
2316 exit_status: Option<i32>,
2318 },
2319 AddNote {
2321 session: String,
2323 note_kind: String,
2325 message: String,
2327 },
2328 Summary {
2330 session: String,
2332 },
2333}
2334
2335#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2337#[serde(rename_all = "camelCase")]
2338pub struct OpsJournalReport {
2339 #[serde(skip_serializing_if = "Option::is_none")]
2341 pub path: Option<PathBuf>,
2342 #[serde(skip_serializing_if = "Option::is_none")]
2344 pub journal: Option<SessionJournal>,
2345 #[serde(skip_serializing_if = "Option::is_none")]
2347 pub markdown: Option<String>,
2348 pub diagnostics: Vec<Diagnostic>,
2350}
2351
2352#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2354#[serde(rename_all = "camelCase")]
2355pub struct SkillsFacadeRequest {
2356 pub repo: Option<PathBuf>,
2358 pub sync: bool,
2360 pub dry_run: bool,
2362}
2363
2364#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2366#[serde(rename_all = "camelCase")]
2367pub struct SkillsFacadeReport {
2368 pub diagnostics: Vec<Diagnostic>,
2370}
2371
2372pub fn validate_project_convention(project: &ProjectManifest) -> Vec<Diagnostic> {
2374 let expected_prefix = match project.kind {
2375 ProjectKind::App => "apps/",
2376 ProjectKind::Framework => "frameworks/",
2377 ProjectKind::FoundationService => "foundations/",
2378 ProjectKind::ProtoRoot => "protos",
2379 ProjectKind::CoreInfra => "core-infra",
2380 ProjectKind::CoreInfraComponent => "core-infra/",
2381 ProjectKind::Tool => "tools/",
2382 };
2383 let valid = match project.kind {
2384 ProjectKind::ProtoRoot | ProjectKind::CoreInfra => project.path.as_str() == expected_prefix,
2385 ProjectKind::App
2386 | ProjectKind::Framework
2387 | ProjectKind::FoundationService
2388 | ProjectKind::CoreInfraComponent
2389 | ProjectKind::Tool => project.path.as_str().starts_with(expected_prefix),
2390 };
2391 if valid {
2392 Vec::new()
2393 } else {
2394 vec![
2395 Diagnostic::error(
2396 "manifest.project.path_convention",
2397 format!(
2398 "project `{}` of kind {:?} must live under `{expected_prefix}`",
2399 project.name, project.kind
2400 ),
2401 )
2402 .with_path(project.source.as_str()),
2403 ]
2404 }
2405}
2406
2407pub fn utf8_path_buf(path: PathBuf) -> Result<Utf8PathBuf, Diagnostic> {
2409 Utf8PathBuf::from_path_buf(path).map_err(|path| {
2410 Diagnostic::error(
2411 "path.non_utf8",
2412 format!("path is not valid UTF-8: {}", path.display()),
2413 )
2414 })
2415}
2416
2417fn validate_ascii_identifier(
2418 label: &str,
2419 value: &str,
2420 max_bytes: usize,
2421 allow_dot: bool,
2422) -> Result<(), Diagnostic> {
2423 if value.is_empty() || value.len() > max_bytes {
2424 return Err(Diagnostic::error(
2425 "manifest.identifier.invalid",
2426 format!("{label} must be non-empty and length-bounded"),
2427 ));
2428 }
2429 let mut previous_dot = false;
2430 for byte in value.bytes() {
2431 let is_dot = byte == b'.';
2432 let allowed = byte.is_ascii_lowercase()
2433 || byte.is_ascii_digit()
2434 || matches!(byte, b'-' | b'_')
2435 || (allow_dot && is_dot);
2436 if !allowed {
2437 return Err(Diagnostic::error(
2438 "manifest.identifier.invalid",
2439 format!("{label} contains unsupported characters"),
2440 ));
2441 }
2442 if allow_dot && is_dot && previous_dot {
2443 return Err(Diagnostic::error(
2444 "manifest.identifier.invalid",
2445 format!("{label} cannot contain consecutive dots"),
2446 ));
2447 }
2448 previous_dot = is_dot;
2449 }
2450 if allow_dot && (value.starts_with('.') || value.ends_with('.')) {
2451 return Err(Diagnostic::error(
2452 "manifest.identifier.invalid",
2453 format!("{label} cannot start or end with a dot"),
2454 ));
2455 }
2456 Ok(())
2457}
2458
2459fn normalize_relative_path(value: String, allow_root: bool) -> Result<String, Diagnostic> {
2460 validate_path_text(&value, false)?;
2461 if value == "." {
2462 return if allow_root {
2463 Ok(value)
2464 } else {
2465 Err(Diagnostic::error(
2466 "manifest.path.invalid",
2467 "repo-relative path cannot be the root path",
2468 ))
2469 };
2470 }
2471 let mut parts = Vec::new();
2472 for part in value.split('/') {
2473 if part.is_empty() || part == "." {
2474 continue;
2475 }
2476 if part == ".." {
2477 return Err(Diagnostic::error(
2478 "manifest.path.traversal",
2479 "relative path cannot contain ..",
2480 ));
2481 }
2482 parts.push(part);
2483 }
2484 if parts.is_empty() {
2485 return if allow_root {
2486 Ok(".".to_string())
2487 } else {
2488 Err(Diagnostic::error(
2489 "manifest.path.invalid",
2490 "relative path must not be empty",
2491 ))
2492 };
2493 }
2494 Ok(parts.join("/"))
2495}
2496
2497fn validate_path_text(value: &str, allow_glob: bool) -> Result<(), Diagnostic> {
2498 if value.is_empty() || value.len() > PATH_MAX_BYTES {
2499 return Err(Diagnostic::error(
2500 "manifest.path.invalid",
2501 "path must be non-empty and length-bounded",
2502 ));
2503 }
2504 if value.contains('\0') || value.contains('\\') {
2505 return Err(Diagnostic::error(
2506 "manifest.path.invalid",
2507 "path must not contain NUL bytes or platform separators",
2508 ));
2509 }
2510 if value.starts_with('/') {
2511 return Err(Diagnostic::error(
2512 "manifest.path.absolute",
2513 "path must be relative",
2514 ));
2515 }
2516 if value
2517 .split('/')
2518 .any(|part| part == ".." || (!allow_glob && part.contains('*')))
2519 {
2520 return Err(Diagnostic::error(
2521 "manifest.path.traversal",
2522 "path must not contain traversal or unsupported glob segments",
2523 ));
2524 }
2525 Ok(())
2526}
2527
2528fn reject_shell_syntax(value: &str) -> Result<(), Diagnostic> {
2529 const REJECTED: [&str; 13] = [
2530 "&&", "||", "|", ";", ">", "<", "$(", "`", "\n", "\r", "*", "?", "[",
2531 ];
2532 if let Some(token) = REJECTED.iter().find(|token| value.contains(**token)) {
2533 return Err(Diagnostic::error(
2534 "manifest.command.shell_syntax",
2535 format!("command uses shell-only syntax `{token}`"),
2536 ));
2537 }
2538 Ok(())
2539}
2540
2541fn split_command(value: &str) -> Result<Vec<String>, Diagnostic> {
2542 let mut parts = Vec::new();
2543 let mut current = String::new();
2544 let mut quote: Option<char> = None;
2545 let mut escaped = false;
2546 for character in value.chars() {
2547 if escaped {
2548 current.push(character);
2549 escaped = false;
2550 continue;
2551 }
2552 if character == '\\' {
2553 escaped = true;
2554 continue;
2555 }
2556 match quote {
2557 Some(active) if character == active => quote = None,
2558 None if character == '\'' || character == '"' => quote = Some(character),
2559 None if character.is_whitespace() => {
2560 if !current.is_empty() {
2561 parts.push(std::mem::take(&mut current));
2562 }
2563 }
2564 Some(_) | None => current.push(character),
2565 }
2566 }
2567 if escaped || quote.is_some() {
2568 return Err(Diagnostic::error(
2569 "manifest.command.invalid",
2570 "command contains an unfinished escape or quote",
2571 ));
2572 }
2573 if !current.is_empty() {
2574 parts.push(current);
2575 }
2576 Ok(parts)
2577}
2578
2579#[cfg(test)]
2580mod tests {
2581 use super::{
2582 CommandSpec, OwnerHandle, ProjectDependency, ProjectRelativePath, RepoRelativePath,
2583 };
2584
2585 #[test]
2586 fn test_should_parse_argv_command() {
2587 let command = CommandSpec::parse("cargo check --workspace").expect("command parses");
2588 assert_eq!(command.program, "cargo");
2589 assert_eq!(command.args, ["check", "--workspace"]);
2590 }
2591
2592 #[test]
2593 fn test_should_reject_shell_syntax_in_command() {
2594 let error = CommandSpec::parse("cargo check && rm -rf target").expect_err("rejects shell");
2595 assert_eq!(error.code.as_ref(), "manifest.command.shell_syntax");
2596 }
2597
2598 #[test]
2599 fn test_should_reject_invalid_owner() {
2600 let error = OwnerHandle::new("platform").expect_err("owner requires @");
2601 assert_eq!(error.code.as_ref(), "manifest.owner.invalid");
2602 }
2603
2604 #[test]
2605 fn test_should_reject_path_traversal() {
2606 let error = RepoRelativePath::new("../outside").expect_err("rejects traversal");
2607 assert_eq!(error.code.as_ref(), "manifest.path.traversal");
2608 }
2609
2610 #[test]
2611 fn test_should_allow_project_root_path() {
2612 let path = ProjectRelativePath::new(".").expect("root is valid");
2613 assert_eq!(path.as_str(), ".");
2614 }
2615
2616 #[test]
2617 fn test_should_parse_foundation_client_dependency() {
2618 let dependency =
2619 ProjectDependency::parse("foundations.identity.client").expect("dependency parses");
2620 assert_eq!(dependency.id, "foundations.identity.client");
2621 }
2622}