1use std::collections::{BTreeMap, BTreeSet};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14mod guidance;
15mod review;
16mod schema_assets;
17mod task_parsing;
18mod types;
19pub use guidance::{
20 load_composed_user_guidance, load_user_guidance, load_user_guidance_for_artifact,
21};
22pub use review::compute_review_context;
23pub use schema_assets::{ExportSchemasResult, export_embedded_schemas};
24use schema_assets::{
25 embedded_schema_names, is_safe_relative_path, is_safe_schema_name, load_embedded_schema_yaml,
26 load_embedded_validation_yaml, package_schemas_dir, project_schemas_dir, read_schema_template,
27 user_schemas_dir,
28};
29use task_parsing::{looks_like_enhanced_tasks, parse_checkbox_tasks, parse_enhanced_tasks};
30pub use types::{
31 AgentInstructionResponse, ApplyInstructionsResponse, ApplyYaml, ArtifactStatus, ArtifactYaml,
32 ChangeStatus, DependencyInfo, InstructionsResponse, PeerReviewContext, ProgressInfo,
33 ResolvedSchema, ReviewAffectedSpecInfo, ReviewArtifactInfo, ReviewCoveredRequirement,
34 ReviewTaskSummaryInfo, ReviewTestingPolicy, ReviewTraceabilityInfo, ReviewUnresolvedReference,
35 ReviewValidationIssueInfo, SchemaSource, SchemaYaml, TaskDiagnostic, TaskItem, TemplateInfo,
36 ValidationArtifactYaml, ValidationDefaultsYaml, ValidationLevelYaml,
37 ValidationTrackingSourceYaml, ValidationTrackingYaml, ValidationYaml, ValidatorId,
38 WorkflowError,
39};
40
41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
43pub struct SchemaListEntry {
44 pub name: String,
46 pub description: String,
48 pub artifacts: Vec<String>,
50 pub source: String,
52}
53
54#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56pub struct SchemaListResponse {
57 pub schemas: Vec<SchemaListEntry>,
59 pub recommended_default: String,
61}
62
63use ito_common::fs::StdFs;
64use ito_common::paths;
65use ito_config::ConfigContext;
66
67pub type TemplatesError = WorkflowError;
69pub fn default_schema_name() -> &'static str {
71 "spec-driven"
72}
73
74pub fn validate_change_name_input(name: &str) -> bool {
92 if name.is_empty() {
93 return false;
94 }
95 if name.starts_with('/') || name.starts_with('\\') {
96 return false;
97 }
98 if name.contains('/') || name.contains('\\') {
99 return false;
100 }
101 if name.contains("..") {
102 return false;
103 }
104 true
105}
106
107pub fn read_change_schema(ito_path: &Path, change: &str) -> String {
120 let meta = paths::change_meta_path(ito_path, change);
121 if let Ok(Some(s)) = ito_common::io::read_to_string_optional(&meta) {
122 let parsed = crate::change_meta::parse_change_meta_best_effort(&s);
123 if let Some(schema) = parsed.schema {
124 return schema;
125 }
126
127 if let Some(schema) = read_legacy_schema_line(&s) {
130 return schema;
131 }
132 }
133 default_schema_name().to_string()
134}
135
136fn read_legacy_schema_line(contents: &str) -> Option<String> {
137 for line in contents.lines() {
138 let line = line.trim();
139 let Some(rest) = line.strip_prefix("schema:") else {
140 continue;
141 };
142 let schema = rest.trim();
143 if !schema.is_empty() {
144 return Some(schema.to_string());
145 }
146 }
147
148 None
149}
150
151pub fn list_available_changes(ito_path: &Path) -> Vec<String> {
164 let fs = StdFs;
165 ito_domain::discovery::list_change_dir_names(&fs, ito_path).unwrap_or_default()
166}
167
168pub fn list_available_schemas(ctx: &ConfigContext) -> Vec<String> {
184 let mut set: BTreeSet<String> = BTreeSet::new();
185 let fs = StdFs;
186 for dir in [
187 project_schemas_dir(ctx),
188 user_schemas_dir(ctx),
189 Some(package_schemas_dir()),
190 ] {
191 let Some(dir) = dir else { continue };
192 let Ok(names) = ito_domain::discovery::list_dir_names(&fs, &dir) else {
193 continue;
194 };
195 for name in names {
196 let schema_dir = dir.join(&name);
197 if schema_dir.join("schema.yaml").exists() {
198 set.insert(name);
199 }
200 }
201 }
202
203 for name in embedded_schema_names() {
204 set.insert(name);
205 }
206
207 set.into_iter().collect()
208}
209
210pub fn list_schemas_detail(ctx: &ConfigContext) -> SchemaListResponse {
226 let names = list_available_schemas(ctx);
227 let mut schemas = Vec::new();
228
229 for name in &names {
230 let Ok(resolved) = resolve_schema(Some(name), ctx) else {
231 continue;
232 };
233 let description = resolved.schema.description.clone().unwrap_or_default();
234 let artifacts: Vec<String> = resolved
235 .schema
236 .artifacts
237 .iter()
238 .map(|a| a.id.clone())
239 .collect();
240 let source = match resolved.source {
241 SchemaSource::Project => "project",
242 SchemaSource::User => "user",
243 SchemaSource::Embedded => "embedded",
244 SchemaSource::Package => "package",
245 }
246 .to_string();
247
248 schemas.push(SchemaListEntry {
249 name: name.clone(),
250 description,
251 artifacts,
252 source,
253 });
254 }
255
256 SchemaListResponse {
257 schemas,
258 recommended_default: default_schema_name().to_string(),
259 }
260}
261
262pub fn resolve_schema(
290 schema_name: Option<&str>,
291 ctx: &ConfigContext,
292) -> Result<ResolvedSchema, TemplatesError> {
293 let name = schema_name.unwrap_or(default_schema_name());
294 if !is_safe_schema_name(name) {
295 return Err(WorkflowError::SchemaNotFound(name.to_string()));
296 }
297
298 let project_dir = project_schemas_dir(ctx).map(|d| d.join(name));
299 if let Some(d) = project_dir
300 && d.join("schema.yaml").exists()
301 {
302 let schema = load_schema_yaml(&d)?;
303 return Ok(ResolvedSchema {
304 schema,
305 schema_dir: d,
306 source: SchemaSource::Project,
307 });
308 }
309
310 let user_dir = user_schemas_dir(ctx).map(|d| d.join(name));
311 if let Some(d) = user_dir
312 && d.join("schema.yaml").exists()
313 {
314 let schema = load_schema_yaml(&d)?;
315 return Ok(ResolvedSchema {
316 schema,
317 schema_dir: d,
318 source: SchemaSource::User,
319 });
320 }
321
322 if let Some(schema) = load_embedded_schema_yaml(name)? {
323 return Ok(ResolvedSchema {
324 schema,
325 schema_dir: PathBuf::from(format!("embedded://schemas/{name}")),
326 source: SchemaSource::Embedded,
327 });
328 }
329
330 let pkg = package_schemas_dir().join(name);
331 if pkg.join("schema.yaml").exists() {
332 let schema = load_schema_yaml(&pkg)?;
333 return Ok(ResolvedSchema {
334 schema,
335 schema_dir: pkg,
336 source: SchemaSource::Package,
337 });
338 }
339
340 Err(TemplatesError::SchemaNotFound(name.to_string()))
341}
342
343pub fn compute_change_status(
379 ito_path: &Path,
380 change: &str,
381 schema_name: Option<&str>,
382 ctx: &ConfigContext,
383) -> Result<ChangeStatus, TemplatesError> {
384 if !validate_change_name_input(change) {
385 return Err(TemplatesError::InvalidChangeName);
386 }
387 let schema_name = schema_name
388 .map(|s| s.to_string())
389 .unwrap_or_else(|| read_change_schema(ito_path, change));
390 let resolved = resolve_schema(Some(&schema_name), ctx)?;
391
392 let change_dir = paths::change_dir(ito_path, change);
393 if !change_dir.exists() {
394 return Err(TemplatesError::ChangeNotFound(change.to_string()));
395 }
396
397 let mut artifacts_out: Vec<ArtifactStatus> = Vec::new();
398 let mut required_done_count: usize = 0;
399 let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
400 let required_count = resolved
401 .schema
402 .artifacts
403 .iter()
404 .filter(|artifact| !artifact.optional)
405 .count();
406
407 let order = build_order(&resolved.schema);
408 for id in order {
409 let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
410 continue;
411 };
412 let done = *done_by_id.get(&a.id).unwrap_or(&false);
413 let mut missing: Vec<String> = Vec::new();
414 if !done {
415 for r in &a.requires {
416 if !*done_by_id.get(r).unwrap_or(&false) {
417 missing.push(r.clone());
418 }
419 }
420 }
421
422 let status = if done {
423 if !a.optional {
424 required_done_count += 1;
425 }
426 "done".to_string()
427 } else if a.optional {
428 "optional".to_string()
429 } else if missing.is_empty() {
430 "ready".to_string()
431 } else {
432 "blocked".to_string()
433 };
434 artifacts_out.push(ArtifactStatus {
435 id: a.id.clone(),
436 output_path: a.generates.clone(),
437 status,
438 missing_deps: missing,
439 });
440 }
441
442 let required_artifact_ids: Vec<String> = resolved
443 .schema
444 .artifacts
445 .iter()
446 .filter(|artifact| !artifact.optional)
447 .map(|a| a.id.clone())
448 .collect();
449 let apply_requires: Vec<String> = match resolved.schema.apply.as_ref() {
450 Some(apply) => apply
451 .requires
452 .clone()
453 .unwrap_or_else(|| required_artifact_ids.clone()),
454 None => required_artifact_ids,
455 };
456 let apply_requires = expand_artifact_requirements(&resolved.schema, &apply_requires);
457
458 let is_complete = required_done_count == required_count;
459 Ok(ChangeStatus {
460 change_name: change.to_string(),
461 schema_name: resolved.schema.name,
462 is_complete,
463 apply_requires,
464 artifacts: artifacts_out,
465 })
466}
467
468fn expand_artifact_requirements(schema: &SchemaYaml, roots: &[String]) -> Vec<String> {
469 let mut required = BTreeSet::new();
470 for root in roots {
471 collect_artifact_requirement(schema, root, &mut required);
472 }
473
474 build_order(schema)
475 .into_iter()
476 .filter(|id| required.contains(id))
477 .collect()
478}
479
480fn collect_artifact_requirement(schema: &SchemaYaml, id: &str, required: &mut BTreeSet<String>) {
481 if !required.insert(id.to_string()) {
482 return;
483 }
484 let Some(artifact) = schema.artifacts.iter().find(|artifact| artifact.id == id) else {
485 return;
486 };
487 for dependency in &artifact.requires {
488 collect_artifact_requirement(schema, dependency, required);
489 }
490}
491
492fn build_order(schema: &SchemaYaml) -> Vec<String> {
546 let mut in_degree: std::collections::BTreeMap<String, usize> =
549 std::collections::BTreeMap::new();
550 let mut dependents: std::collections::BTreeMap<String, Vec<String>> =
551 std::collections::BTreeMap::new();
552
553 for a in &schema.artifacts {
554 in_degree.insert(a.id.clone(), a.requires.len());
555 dependents.insert(a.id.clone(), Vec::new());
556 }
557 for a in &schema.artifacts {
558 for req in &a.requires {
559 dependents
560 .entry(req.clone())
561 .or_default()
562 .push(a.id.clone());
563 }
564 }
565
566 let mut queue: Vec<String> = schema
567 .artifacts
568 .iter()
569 .map(|a| a.id.clone())
570 .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
571 .collect();
572 queue.sort();
573
574 let mut result: Vec<String> = Vec::new();
575 while !queue.is_empty() {
576 let current = queue.remove(0);
577 result.push(current.clone());
578
579 let mut newly_ready: Vec<String> = Vec::new();
580 if let Some(deps) = dependents.get(¤t) {
581 for dep in deps {
582 let new_degree = in_degree.get(dep).copied().unwrap_or(0).saturating_sub(1);
583 in_degree.insert(dep.clone(), new_degree);
584 if new_degree == 0 {
585 newly_ready.push(dep.clone());
586 }
587 }
588 }
589 newly_ready.sort();
590 queue.extend(newly_ready);
591 }
592
593 result
594}
595
596pub fn resolve_templates(
613 schema_name: Option<&str>,
614 ctx: &ConfigContext,
615) -> Result<(String, BTreeMap<String, TemplateInfo>), TemplatesError> {
616 let resolved = resolve_schema(schema_name, ctx)?;
617
618 let mut templates: BTreeMap<String, TemplateInfo> = BTreeMap::new();
619 for a in &resolved.schema.artifacts {
620 if !is_safe_relative_path(&a.template) {
621 return Err(WorkflowError::Io(std::io::Error::new(
622 std::io::ErrorKind::InvalidInput,
623 format!("invalid template path: {}", a.template),
624 )));
625 }
626
627 let path = if resolved.source == SchemaSource::Embedded {
628 format!(
629 "embedded://schemas/{}/templates/{}",
630 resolved.schema.name, a.template
631 )
632 } else {
633 resolved
634 .schema_dir
635 .join("templates")
636 .join(&a.template)
637 .to_string_lossy()
638 .to_string()
639 };
640 templates.insert(
641 a.id.clone(),
642 TemplateInfo {
643 source: resolved.source.as_str().to_string(),
644 path,
645 },
646 );
647 }
648 Ok((resolved.schema.name, templates))
649}
650
651pub fn resolve_instructions(
679 ito_path: &Path,
680 change: &str,
681 schema_name: Option<&str>,
682 artifact_id: &str,
683 ctx: &ConfigContext,
684) -> Result<InstructionsResponse, TemplatesError> {
685 if !validate_change_name_input(change) {
686 return Err(TemplatesError::InvalidChangeName);
687 }
688 let schema_name = schema_name
689 .map(|s| s.to_string())
690 .unwrap_or_else(|| read_change_schema(ito_path, change));
691 let resolved = resolve_schema(Some(&schema_name), ctx)?;
692
693 let change_dir = paths::change_dir(ito_path, change);
694 if !change_dir.exists() {
695 return Err(TemplatesError::ChangeNotFound(change.to_string()));
696 }
697
698 let a = resolved
699 .schema
700 .artifacts
701 .iter()
702 .find(|a| a.id == artifact_id)
703 .ok_or_else(|| TemplatesError::ArtifactNotFound(artifact_id.to_string()))?;
704
705 let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
706
707 let deps: Vec<DependencyInfo> = a
708 .requires
709 .iter()
710 .map(|id| {
711 let dep = resolved.schema.artifacts.iter().find(|d| d.id == *id);
712 DependencyInfo {
713 id: id.clone(),
714 done: *done_by_id.get(id).unwrap_or(&false),
715 path: dep
716 .map(|d| d.generates.clone())
717 .unwrap_or_else(|| id.clone()),
718 description: dep.and_then(|d| d.description.clone()).unwrap_or_default(),
719 }
720 })
721 .collect();
722
723 let mut unlocks: Vec<String> = resolved
724 .schema
725 .artifacts
726 .iter()
727 .filter(|other| other.requires.iter().any(|r| r == artifact_id))
728 .map(|a| a.id.clone())
729 .collect();
730 unlocks.sort();
731
732 let template = read_schema_template(&resolved, &a.template)?;
733
734 Ok(InstructionsResponse {
735 change_name: change.to_string(),
736 artifact_id: a.id.clone(),
737 schema_name: resolved.schema.name,
738 change_dir: change_dir.to_string_lossy().to_string(),
739 output_path: a.generates.clone(),
740 description: a.description.clone().unwrap_or_default(),
741 instruction: a.instruction.clone(),
742 template,
743 dependencies: deps,
744 unlocks,
745 })
746}
747
748pub fn compute_apply_instructions(
753 ito_path: &Path,
754 change: &str,
755 schema_name: Option<&str>,
756 ctx: &ConfigContext,
757) -> Result<ApplyInstructionsResponse, TemplatesError> {
758 if !validate_change_name_input(change) {
759 return Err(TemplatesError::InvalidChangeName);
760 }
761 let schema_name = schema_name
762 .map(|s| s.to_string())
763 .unwrap_or_else(|| read_change_schema(ito_path, change));
764 let resolved = resolve_schema(Some(&schema_name), ctx)?;
765 let change_dir = paths::change_dir(ito_path, change);
766 if !change_dir.exists() {
767 return Err(TemplatesError::ChangeNotFound(change.to_string()));
768 }
769
770 let schema = &resolved.schema;
771 let apply = schema.apply.as_ref();
772 let required_artifact_ids: Vec<String> = schema
773 .artifacts
774 .iter()
775 .filter(|artifact| !artifact.optional)
776 .map(|a| a.id.clone())
777 .collect();
778
779 let apply_required_artifact_ids: Vec<String> = apply
782 .and_then(|a| a.requires.clone())
783 .unwrap_or(required_artifact_ids);
784 let tracks_file: Option<String> = apply.and_then(|a| a.tracks.clone());
785 let schema_instruction: Option<String> = apply.and_then(|a| a.instruction.clone());
786
787 let mut missing_artifacts: Vec<String> = Vec::new();
789 for artifact_id in &apply_required_artifact_ids {
790 let Some(artifact) = schema.artifacts.iter().find(|a| a.id == *artifact_id) else {
791 continue;
792 };
793 if !artifact_done(&change_dir, &artifact.generates) {
794 missing_artifacts.push(artifact_id.clone());
795 }
796 }
797
798 let mut context_files: BTreeMap<String, String> = BTreeMap::new();
800 for artifact in &schema.artifacts {
801 if artifact_done(&change_dir, &artifact.generates) {
802 context_files.insert(
803 artifact.id.clone(),
804 change_dir
805 .join(&artifact.generates)
806 .to_string_lossy()
807 .to_string(),
808 );
809 }
810 }
811
812 let mut tasks: Vec<TaskItem> = Vec::new();
814 let mut tracks_file_exists = false;
815 let mut tracks_path: Option<String> = None;
816 let mut tracks_format: Option<String> = None;
817 let tracks_diagnostics: Option<Vec<TaskDiagnostic>> = None;
818
819 if let Some(tf) = &tracks_file {
820 let p = change_dir.join(tf);
821 tracks_path = Some(p.to_string_lossy().to_string());
822 tracks_file_exists = p.exists();
823 if tracks_file_exists {
824 let content = ito_common::io::read_to_string_std(&p)?;
825 let checkbox = parse_checkbox_tasks(&content);
826 if !checkbox.is_empty() {
827 tracks_format = Some("checkbox".to_string());
828 tasks = checkbox;
829 } else {
830 let enhanced = parse_enhanced_tasks(&content);
831 if !enhanced.is_empty() {
832 tracks_format = Some("enhanced".to_string());
833 tasks = enhanced;
834 } else if looks_like_enhanced_tasks(&content) {
835 tracks_format = Some("enhanced".to_string());
836 } else {
837 tracks_format = Some("unknown".to_string());
838 }
839 }
840 }
841 }
842
843 let total = tasks.len();
845 let complete = tasks.iter().filter(|t| t.done).count();
846 let remaining = total.saturating_sub(complete);
847 let mut in_progress: Option<usize> = None;
848 let mut pending: Option<usize> = None;
849 if tracks_format.as_deref() == Some("enhanced") {
850 let mut in_progress_count = 0;
851 let mut pending_count = 0;
852 for task in &tasks {
853 let Some(status) = task.status.as_deref() else {
854 continue;
855 };
856 let status = status.trim();
857 match status {
858 "in-progress" | "in_progress" | "in progress" => in_progress_count += 1,
859 "pending" => pending_count += 1,
860 _ => {}
861 }
862 }
863 in_progress = Some(in_progress_count);
864 pending = Some(pending_count);
865 }
866 if tracks_format.as_deref() == Some("checkbox") {
867 let mut in_progress_count = 0;
868 for task in &tasks {
869 let Some(status) = task.status.as_deref() else {
870 continue;
871 };
872 if status.trim() == "in-progress" {
873 in_progress_count += 1;
874 }
875 }
876 in_progress = Some(in_progress_count);
877 pending = Some(total.saturating_sub(complete + in_progress_count));
878 }
879 let progress = ProgressInfo {
880 total,
881 complete,
882 remaining,
883 in_progress,
884 pending,
885 };
886
887 let (state, instruction) = if !missing_artifacts.is_empty() {
889 (
890 "blocked".to_string(),
891 format!(
892 "Cannot apply this change yet. Missing artifacts: {}.\nUse the ito-proposal lifecycle skill to complete the proposal package first.",
893 missing_artifacts.join(", ")
894 ),
895 )
896 } else if tracks_file.is_some() && !tracks_file_exists {
897 let tracks_filename = tracks_file
898 .as_deref()
899 .and_then(|p| Path::new(p).file_name())
900 .map(|s| s.to_string_lossy().to_string())
901 .unwrap_or_else(|| "tasks.md".to_string());
902 (
903 "blocked".to_string(),
904 format!(
905 "The {tracks_filename} file is missing and must be created.\nUse ito-proposal to complete the proposal package."
906 ),
907 )
908 } else if tracks_file.is_some() && tracks_file_exists && total == 0 {
909 let tracks_filename = tracks_file
910 .as_deref()
911 .and_then(|p| Path::new(p).file_name())
912 .map(|s| s.to_string_lossy().to_string())
913 .unwrap_or_else(|| "tasks.md".to_string());
914 (
915 "blocked".to_string(),
916 format!(
917 "The {tracks_filename} file exists but contains no tasks.\nAdd tasks to {tracks_filename} through ito-proposal."
918 ),
919 )
920 } else if tracks_file.is_some() && remaining == 0 && total > 0 {
921 (
922 "all_done".to_string(),
923 "All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving."
924 .to_string(),
925 )
926 } else if tracks_file.is_none() {
927 (
928 "ready".to_string(),
929 schema_instruction
930 .as_deref()
931 .map(|s| s.trim().to_string())
932 .unwrap_or_else(|| {
933 "All required artifacts complete. Proceed with implementation.".to_string()
934 }),
935 )
936 } else {
937 (
938 "ready".to_string(),
939 schema_instruction
940 .as_deref()
941 .map(|s| s.trim().to_string())
942 .unwrap_or_else(|| {
943 "Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.".to_string()
944 }),
945 )
946 };
947
948 Ok(ApplyInstructionsResponse {
949 change_name: change.to_string(),
950 change_dir: change_dir.to_string_lossy().to_string(),
951 schema_name: schema.name.clone(),
952 tracks_path,
953 tracks_file,
954 tracks_format,
955 tracks_diagnostics,
956 context_files,
957 progress,
958 tasks,
959 state,
960 missing_artifacts: if missing_artifacts.is_empty() {
961 None
962 } else {
963 Some(missing_artifacts)
964 },
965 instruction,
966 })
967}
968
969fn load_schema_yaml(schema_dir: &Path) -> Result<SchemaYaml, WorkflowError> {
970 let s = ito_common::io::read_to_string_std(&schema_dir.join("schema.yaml"))?;
971 Ok(serde_yaml::from_str(&s)?)
972}
973
974fn load_validation_yaml(schema_dir: &Path) -> Result<Option<ValidationYaml>, WorkflowError> {
975 let path = schema_dir.join("validation.yaml");
976 if !path.exists() {
977 return Ok(None);
978 }
979 let s = ito_common::io::read_to_string_std(&path)?;
980 Ok(Some(serde_yaml::from_str(&s)?))
981}
982
983pub fn load_schema_validation(
985 resolved: &ResolvedSchema,
986) -> Result<Option<ValidationYaml>, WorkflowError> {
987 if resolved.source == SchemaSource::Embedded {
988 return load_embedded_validation_yaml(&resolved.schema.name);
989 }
990 load_validation_yaml(&resolved.schema_dir)
991}
992
993fn compute_done_by_id(change_dir: &Path, schema: &SchemaYaml) -> BTreeMap<String, bool> {
994 let mut out = BTreeMap::new();
995 for a in &schema.artifacts {
996 out.insert(a.id.clone(), artifact_done(change_dir, &a.generates));
997 }
998 out
999}
1000
1001pub(crate) fn artifact_done(change_dir: &Path, generates: &str) -> bool {
1006 if !generates.contains('*') {
1007 return change_dir.join(generates).exists();
1008 }
1009
1010 let (base, suffix) = match split_glob_pattern(generates) {
1015 Some(v) => v,
1016 None => return false,
1017 };
1018 let base_dir = change_dir.join(base);
1019 dir_contains_filename_suffix(&base_dir, &suffix)
1020}
1021
1022fn split_glob_pattern(pattern: &str) -> Option<(String, String)> {
1023 let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
1024
1025 let (dir_part, file_pat) = match pattern.rsplit_once('/') {
1026 Some((d, f)) => (d, f),
1027 None => ("", pattern),
1028 };
1029 if !file_pat.starts_with('*') {
1030 return None;
1031 }
1032 let suffix = file_pat[1..].to_string();
1033
1034 let base = dir_part
1035 .strip_suffix("/**")
1036 .or_else(|| dir_part.strip_suffix("**"))
1037 .unwrap_or(dir_part);
1038
1039 let base = if base.contains('*') { "" } else { base };
1041 Some((base.to_string(), suffix))
1042}
1043
1044fn dir_contains_filename_suffix(dir: &Path, suffix: &str) -> bool {
1045 let Ok(entries) = fs::read_dir(dir) else {
1046 return false;
1047 };
1048 for e in entries.flatten() {
1049 let path = e.path();
1050 if e.file_type().ok().is_some_and(|t| t.is_dir()) {
1051 if dir_contains_filename_suffix(&path, suffix) {
1052 return true;
1053 }
1054 continue;
1055 }
1056 let name = e.file_name().to_string_lossy().to_string();
1057 if name.ends_with(suffix) {
1058 return true;
1059 }
1060 }
1061 false
1062}
1063
1064