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(
378 ito_path: &Path,
379 change: &str,
380 schema_name: Option<&str>,
381 ctx: &ConfigContext,
382) -> Result<ChangeStatus, TemplatesError> {
383 if !validate_change_name_input(change) {
384 return Err(TemplatesError::InvalidChangeName);
385 }
386 let schema_name = schema_name
387 .map(|s| s.to_string())
388 .unwrap_or_else(|| read_change_schema(ito_path, change));
389 let resolved = resolve_schema(Some(&schema_name), ctx)?;
390
391 let change_dir = paths::change_dir(ito_path, change);
392 if !change_dir.exists() {
393 return Err(TemplatesError::ChangeNotFound(change.to_string()));
394 }
395
396 let mut artifacts_out: Vec<ArtifactStatus> = Vec::new();
397 let mut done_count: usize = 0;
398 let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
399
400 let order = build_order(&resolved.schema);
401 for id in order {
402 let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
403 continue;
404 };
405 let done = *done_by_id.get(&a.id).unwrap_or(&false);
406 let mut missing: Vec<String> = Vec::new();
407 if !done {
408 for r in &a.requires {
409 if !*done_by_id.get(r).unwrap_or(&false) {
410 missing.push(r.clone());
411 }
412 }
413 }
414
415 let status = if done {
416 done_count += 1;
417 "done".to_string()
418 } else if missing.is_empty() {
419 "ready".to_string()
420 } else {
421 "blocked".to_string()
422 };
423 artifacts_out.push(ArtifactStatus {
424 id: a.id.clone(),
425 output_path: a.generates.clone(),
426 status,
427 missing_deps: missing,
428 });
429 }
430
431 let all_artifact_ids: Vec<String> = resolved
432 .schema
433 .artifacts
434 .iter()
435 .map(|a| a.id.clone())
436 .collect();
437 let apply_requires: Vec<String> = match resolved.schema.apply.as_ref() {
438 Some(apply) => apply
439 .requires
440 .clone()
441 .unwrap_or_else(|| all_artifact_ids.clone()),
442 None => all_artifact_ids.clone(),
443 };
444
445 let is_complete = done_count == resolved.schema.artifacts.len();
446 Ok(ChangeStatus {
447 change_name: change.to_string(),
448 schema_name: resolved.schema.name,
449 is_complete,
450 apply_requires,
451 artifacts: artifacts_out,
452 })
453}
454
455fn build_order(schema: &SchemaYaml) -> Vec<String> {
506 let mut in_degree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
509 let mut dependents: std::collections::HashMap<String, Vec<String>> =
510 std::collections::HashMap::new();
511
512 for a in &schema.artifacts {
513 in_degree.insert(a.id.clone(), a.requires.len());
514 dependents.insert(a.id.clone(), Vec::new());
515 }
516 for a in &schema.artifacts {
517 for req in &a.requires {
518 dependents
519 .entry(req.clone())
520 .or_default()
521 .push(a.id.clone());
522 }
523 }
524
525 let mut queue: Vec<String> = schema
526 .artifacts
527 .iter()
528 .map(|a| a.id.clone())
529 .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
530 .collect();
531 queue.sort();
532
533 let mut result: Vec<String> = Vec::new();
534 while !queue.is_empty() {
535 let current = queue.remove(0);
536 result.push(current.clone());
537
538 let mut newly_ready: Vec<String> = Vec::new();
539 if let Some(deps) = dependents.get(¤t) {
540 for dep in deps {
541 let new_degree = in_degree.get(dep).copied().unwrap_or(0).saturating_sub(1);
542 in_degree.insert(dep.clone(), new_degree);
543 if new_degree == 0 {
544 newly_ready.push(dep.clone());
545 }
546 }
547 }
548 newly_ready.sort();
549 queue.extend(newly_ready);
550 }
551
552 result
553}
554
555pub fn resolve_templates(
572 schema_name: Option<&str>,
573 ctx: &ConfigContext,
574) -> Result<(String, BTreeMap<String, TemplateInfo>), TemplatesError> {
575 let resolved = resolve_schema(schema_name, ctx)?;
576
577 let mut templates: BTreeMap<String, TemplateInfo> = BTreeMap::new();
578 for a in &resolved.schema.artifacts {
579 if !is_safe_relative_path(&a.template) {
580 return Err(WorkflowError::Io(std::io::Error::new(
581 std::io::ErrorKind::InvalidInput,
582 format!("invalid template path: {}", a.template),
583 )));
584 }
585
586 let path = if resolved.source == SchemaSource::Embedded {
587 format!(
588 "embedded://schemas/{}/templates/{}",
589 resolved.schema.name, a.template
590 )
591 } else {
592 resolved
593 .schema_dir
594 .join("templates")
595 .join(&a.template)
596 .to_string_lossy()
597 .to_string()
598 };
599 templates.insert(
600 a.id.clone(),
601 TemplateInfo {
602 source: resolved.source.as_str().to_string(),
603 path,
604 },
605 );
606 }
607 Ok((resolved.schema.name, templates))
608}
609
610pub fn resolve_instructions(
638 ito_path: &Path,
639 change: &str,
640 schema_name: Option<&str>,
641 artifact_id: &str,
642 ctx: &ConfigContext,
643) -> Result<InstructionsResponse, TemplatesError> {
644 if !validate_change_name_input(change) {
645 return Err(TemplatesError::InvalidChangeName);
646 }
647 let schema_name = schema_name
648 .map(|s| s.to_string())
649 .unwrap_or_else(|| read_change_schema(ito_path, change));
650 let resolved = resolve_schema(Some(&schema_name), ctx)?;
651
652 let change_dir = paths::change_dir(ito_path, change);
653 if !change_dir.exists() {
654 return Err(TemplatesError::ChangeNotFound(change.to_string()));
655 }
656
657 let a = resolved
658 .schema
659 .artifacts
660 .iter()
661 .find(|a| a.id == artifact_id)
662 .ok_or_else(|| TemplatesError::ArtifactNotFound(artifact_id.to_string()))?;
663
664 let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
665
666 let deps: Vec<DependencyInfo> = a
667 .requires
668 .iter()
669 .map(|id| {
670 let dep = resolved.schema.artifacts.iter().find(|d| d.id == *id);
671 DependencyInfo {
672 id: id.clone(),
673 done: *done_by_id.get(id).unwrap_or(&false),
674 path: dep
675 .map(|d| d.generates.clone())
676 .unwrap_or_else(|| id.clone()),
677 description: dep.and_then(|d| d.description.clone()).unwrap_or_default(),
678 }
679 })
680 .collect();
681
682 let mut unlocks: Vec<String> = resolved
683 .schema
684 .artifacts
685 .iter()
686 .filter(|other| other.requires.iter().any(|r| r == artifact_id))
687 .map(|a| a.id.clone())
688 .collect();
689 unlocks.sort();
690
691 let template = read_schema_template(&resolved, &a.template)?;
692
693 Ok(InstructionsResponse {
694 change_name: change.to_string(),
695 artifact_id: a.id.clone(),
696 schema_name: resolved.schema.name,
697 change_dir: change_dir.to_string_lossy().to_string(),
698 output_path: a.generates.clone(),
699 description: a.description.clone().unwrap_or_default(),
700 instruction: a.instruction.clone(),
701 template,
702 dependencies: deps,
703 unlocks,
704 })
705}
706
707pub fn compute_apply_instructions(
709 ito_path: &Path,
710 change: &str,
711 schema_name: Option<&str>,
712 ctx: &ConfigContext,
713) -> Result<ApplyInstructionsResponse, TemplatesError> {
714 if !validate_change_name_input(change) {
715 return Err(TemplatesError::InvalidChangeName);
716 }
717 let schema_name = schema_name
718 .map(|s| s.to_string())
719 .unwrap_or_else(|| read_change_schema(ito_path, change));
720 let resolved = resolve_schema(Some(&schema_name), ctx)?;
721 let change_dir = paths::change_dir(ito_path, change);
722 if !change_dir.exists() {
723 return Err(TemplatesError::ChangeNotFound(change.to_string()));
724 }
725
726 let schema = &resolved.schema;
727 let apply = schema.apply.as_ref();
728 let all_artifact_ids: Vec<String> = schema.artifacts.iter().map(|a| a.id.clone()).collect();
729
730 let required_artifact_ids: Vec<String> = apply
733 .and_then(|a| a.requires.clone())
734 .unwrap_or_else(|| all_artifact_ids.clone());
735 let tracks_file: Option<String> = apply.and_then(|a| a.tracks.clone());
736 let schema_instruction: Option<String> = apply.and_then(|a| a.instruction.clone());
737
738 let mut missing_artifacts: Vec<String> = Vec::new();
740 for artifact_id in &required_artifact_ids {
741 let Some(artifact) = schema.artifacts.iter().find(|a| a.id == *artifact_id) else {
742 continue;
743 };
744 if !artifact_done(&change_dir, &artifact.generates) {
745 missing_artifacts.push(artifact_id.clone());
746 }
747 }
748
749 let mut context_files: BTreeMap<String, String> = BTreeMap::new();
751 for artifact in &schema.artifacts {
752 if artifact_done(&change_dir, &artifact.generates) {
753 context_files.insert(
754 artifact.id.clone(),
755 change_dir
756 .join(&artifact.generates)
757 .to_string_lossy()
758 .to_string(),
759 );
760 }
761 }
762
763 let mut tasks: Vec<TaskItem> = Vec::new();
765 let mut tracks_file_exists = false;
766 let mut tracks_path: Option<String> = None;
767 let mut tracks_format: Option<String> = None;
768 let tracks_diagnostics: Option<Vec<TaskDiagnostic>> = None;
769
770 if let Some(tf) = &tracks_file {
771 let p = change_dir.join(tf);
772 tracks_path = Some(p.to_string_lossy().to_string());
773 tracks_file_exists = p.exists();
774 if tracks_file_exists {
775 let content = ito_common::io::read_to_string_std(&p)?;
776 let checkbox = parse_checkbox_tasks(&content);
777 if !checkbox.is_empty() {
778 tracks_format = Some("checkbox".to_string());
779 tasks = checkbox;
780 } else {
781 let enhanced = parse_enhanced_tasks(&content);
782 if !enhanced.is_empty() {
783 tracks_format = Some("enhanced".to_string());
784 tasks = enhanced;
785 } else if looks_like_enhanced_tasks(&content) {
786 tracks_format = Some("enhanced".to_string());
787 } else {
788 tracks_format = Some("unknown".to_string());
789 }
790 }
791 }
792 }
793
794 let total = tasks.len();
796 let complete = tasks.iter().filter(|t| t.done).count();
797 let remaining = total.saturating_sub(complete);
798 let mut in_progress: Option<usize> = None;
799 let mut pending: Option<usize> = None;
800 if tracks_format.as_deref() == Some("enhanced") {
801 let mut in_progress_count = 0;
802 let mut pending_count = 0;
803 for task in &tasks {
804 let Some(status) = task.status.as_deref() else {
805 continue;
806 };
807 let status = status.trim();
808 match status {
809 "in-progress" | "in_progress" | "in progress" => in_progress_count += 1,
810 "pending" => pending_count += 1,
811 _ => {}
812 }
813 }
814 in_progress = Some(in_progress_count);
815 pending = Some(pending_count);
816 }
817 if tracks_format.as_deref() == Some("checkbox") {
818 let mut in_progress_count = 0;
819 for task in &tasks {
820 let Some(status) = task.status.as_deref() else {
821 continue;
822 };
823 if status.trim() == "in-progress" {
824 in_progress_count += 1;
825 }
826 }
827 in_progress = Some(in_progress_count);
828 pending = Some(total.saturating_sub(complete + in_progress_count));
829 }
830 let progress = ProgressInfo {
831 total,
832 complete,
833 remaining,
834 in_progress,
835 pending,
836 };
837
838 let (state, instruction) = if !missing_artifacts.is_empty() {
840 (
841 "blocked".to_string(),
842 format!(
843 "Cannot apply this change yet. Missing artifacts: {}.\nUse the ito-continue-change skill to create the missing artifacts first.",
844 missing_artifacts.join(", ")
845 ),
846 )
847 } else if tracks_file.is_some() && !tracks_file_exists {
848 let tracks_filename = tracks_file
849 .as_deref()
850 .and_then(|p| Path::new(p).file_name())
851 .map(|s| s.to_string_lossy().to_string())
852 .unwrap_or_else(|| "tasks.md".to_string());
853 (
854 "blocked".to_string(),
855 format!(
856 "The {tracks_filename} file is missing and must be created.\nUse ito-continue-change to generate the tracking file."
857 ),
858 )
859 } else if tracks_file.is_some() && tracks_file_exists && total == 0 {
860 let tracks_filename = tracks_file
861 .as_deref()
862 .and_then(|p| Path::new(p).file_name())
863 .map(|s| s.to_string_lossy().to_string())
864 .unwrap_or_else(|| "tasks.md".to_string());
865 (
866 "blocked".to_string(),
867 format!(
868 "The {tracks_filename} file exists but contains no tasks.\nAdd tasks to {tracks_filename} or regenerate it with ito-continue-change."
869 ),
870 )
871 } else if tracks_file.is_some() && remaining == 0 && total > 0 {
872 (
873 "all_done".to_string(),
874 "All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving."
875 .to_string(),
876 )
877 } else if tracks_file.is_none() {
878 (
879 "ready".to_string(),
880 schema_instruction
881 .as_deref()
882 .map(|s| s.trim().to_string())
883 .unwrap_or_else(|| {
884 "All required artifacts complete. Proceed with implementation.".to_string()
885 }),
886 )
887 } else {
888 (
889 "ready".to_string(),
890 schema_instruction
891 .as_deref()
892 .map(|s| s.trim().to_string())
893 .unwrap_or_else(|| {
894 "Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.".to_string()
895 }),
896 )
897 };
898
899 Ok(ApplyInstructionsResponse {
900 change_name: change.to_string(),
901 change_dir: change_dir.to_string_lossy().to_string(),
902 schema_name: schema.name.clone(),
903 tracks_path,
904 tracks_file,
905 tracks_format,
906 tracks_diagnostics,
907 context_files,
908 progress,
909 tasks,
910 state,
911 missing_artifacts: if missing_artifacts.is_empty() {
912 None
913 } else {
914 Some(missing_artifacts)
915 },
916 instruction,
917 })
918}
919
920fn load_schema_yaml(schema_dir: &Path) -> Result<SchemaYaml, WorkflowError> {
921 let s = ito_common::io::read_to_string_std(&schema_dir.join("schema.yaml"))?;
922 Ok(serde_yaml::from_str(&s)?)
923}
924
925fn load_validation_yaml(schema_dir: &Path) -> Result<Option<ValidationYaml>, WorkflowError> {
926 let path = schema_dir.join("validation.yaml");
927 if !path.exists() {
928 return Ok(None);
929 }
930 let s = ito_common::io::read_to_string_std(&path)?;
931 Ok(Some(serde_yaml::from_str(&s)?))
932}
933
934pub fn load_schema_validation(
936 resolved: &ResolvedSchema,
937) -> Result<Option<ValidationYaml>, WorkflowError> {
938 if resolved.source == SchemaSource::Embedded {
939 return load_embedded_validation_yaml(&resolved.schema.name);
940 }
941 load_validation_yaml(&resolved.schema_dir)
942}
943
944fn compute_done_by_id(change_dir: &Path, schema: &SchemaYaml) -> BTreeMap<String, bool> {
945 let mut out = BTreeMap::new();
946 for a in &schema.artifacts {
947 out.insert(a.id.clone(), artifact_done(change_dir, &a.generates));
948 }
949 out
950}
951
952pub(crate) fn artifact_done(change_dir: &Path, generates: &str) -> bool {
957 if !generates.contains('*') {
958 return change_dir.join(generates).exists();
959 }
960
961 let (base, suffix) = match split_glob_pattern(generates) {
966 Some(v) => v,
967 None => return false,
968 };
969 let base_dir = change_dir.join(base);
970 dir_contains_filename_suffix(&base_dir, &suffix)
971}
972
973fn split_glob_pattern(pattern: &str) -> Option<(String, String)> {
974 let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
975
976 let (dir_part, file_pat) = match pattern.rsplit_once('/') {
977 Some((d, f)) => (d, f),
978 None => ("", pattern),
979 };
980 if !file_pat.starts_with('*') {
981 return None;
982 }
983 let suffix = file_pat[1..].to_string();
984
985 let base = dir_part
986 .strip_suffix("/**")
987 .or_else(|| dir_part.strip_suffix("**"))
988 .unwrap_or(dir_part);
989
990 let base = if base.contains('*') { "" } else { base };
992 Some((base.to_string(), suffix))
993}
994
995fn dir_contains_filename_suffix(dir: &Path, suffix: &str) -> bool {
996 let Ok(entries) = fs::read_dir(dir) else {
997 return false;
998 };
999 for e in entries.flatten() {
1000 let path = e.path();
1001 if e.file_type().ok().is_some_and(|t| t.is_dir()) {
1002 if dir_contains_filename_suffix(&path, suffix) {
1003 return true;
1004 }
1005 continue;
1006 }
1007 let name = e.file_name().to_string_lossy().to_string();
1008 if name.ends_with(suffix) {
1009 return true;
1010 }
1011 }
1012 false
1013}
1014
1015