ito_core/templates/
guidance.rs

1use ito_templates::ITO_END_MARKER;
2use std::path::Path;
3
4use super::WorkflowError;
5
6const ITO_INTERNAL_COMMENT_START: &str = "<!-- ITO:INTERNAL:START -->";
7const ITO_INTERNAL_COMMENT_END: &str = "<!-- ITO:INTERNAL:END -->";
8
9/// Load shared user guidance text.
10///
11/// Prefers `.ito/user-prompts/guidance.md`, with fallback to `.ito/user-guidance.md`.
12pub fn load_user_guidance(ito_path: &Path) -> Result<Option<String>, WorkflowError> {
13    let path = ito_path.join("user-prompts").join("guidance.md");
14    if path.exists() {
15        return load_guidance_file(&path);
16    }
17    let path = ito_path.join("user-guidance.md");
18    load_guidance_file(&path)
19}
20
21/// Load artifact-scoped user guidance text from `.ito/user-prompts/<artifact-id>.md`.
22pub fn load_user_guidance_for_artifact(
23    ito_path: &Path,
24    artifact_id: &str,
25) -> Result<Option<String>, WorkflowError> {
26    if !is_safe_artifact_id(artifact_id) {
27        return Err(WorkflowError::InvalidArtifactId(artifact_id.to_string()));
28    }
29    let path = ito_path
30        .join("user-prompts")
31        .join(format!("{artifact_id}.md"));
32    load_guidance_file(&path)
33}
34
35/// Compose scoped and shared user guidance into one guidance string.
36///
37/// When both scoped and shared guidance are present, the returned text contains:
38/// - `## Scoped Guidance (<artifact_id>)`
39/// - `## Shared Guidance`
40///
41/// If only one source exists, that content is returned. If neither exists, returns `None`.
42pub fn load_composed_user_guidance(
43    ito_path: &Path,
44    artifact_id: &str,
45) -> Result<Option<String>, WorkflowError> {
46    let scoped = load_user_guidance_for_artifact(ito_path, artifact_id)?;
47    let shared = load_user_guidance(ito_path)?;
48
49    match (scoped, shared) {
50        (None, None) => Ok(None),
51        (Some(scoped), None) => Ok(Some(scoped)),
52        (None, Some(shared)) => Ok(Some(shared)),
53        (Some(scoped), Some(shared)) => Ok(Some(format!(
54            "## Scoped Guidance ({artifact_id})\n\n{scoped}\n\n## Shared Guidance\n\n{shared}"
55        ))),
56    }
57}
58
59/// Load and normalize a guidance file from disk.
60///
61/// Behavior:
62/// - returns `Ok(None)` if the file does not exist,
63/// - normalizes CRLF to LF,
64/// - removes managed content before `ITO_END_MARKER`,
65/// - removes internal placeholder blocks between
66///   `<!-- ITO:INTERNAL:START -->` and `<!-- ITO:INTERNAL:END -->`,
67/// - trims whitespace and returns `Ok(None)` when empty.
68fn load_guidance_file(path: &Path) -> Result<Option<String>, WorkflowError> {
69    if !path.exists() {
70        return Ok(None);
71    }
72
73    let content = ito_common::io::read_to_string_std(path)?;
74    let content = content.replace("\r\n", "\n");
75    let content = match content.find(ITO_END_MARKER) {
76        Some(i) => &content[i + ITO_END_MARKER.len()..],
77        None => content.as_str(),
78    };
79    let content = strip_ito_internal_comment_blocks(content);
80    let content = content.trim();
81    if content.is_empty() {
82        return Ok(None);
83    }
84
85    Ok(Some(content.to_string()))
86}
87
88fn strip_ito_internal_comment_blocks(content: &str) -> String {
89    let mut out = String::new();
90    let mut in_block = false;
91
92    for line in content.lines() {
93        let trimmed = line.trim();
94
95        if in_block {
96            if trimmed == ITO_INTERNAL_COMMENT_END {
97                in_block = false;
98            }
99            continue;
100        }
101
102        if trimmed == ITO_INTERNAL_COMMENT_START {
103            in_block = true;
104            continue;
105        }
106
107        out.push_str(line);
108        out.push('\n');
109    }
110
111    out
112}
113
114fn is_safe_artifact_id(artifact_id: &str) -> bool {
115    if artifact_id.is_empty() || artifact_id.contains("..") {
116        return false;
117    }
118
119    for c in artifact_id.chars() {
120        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
121            return false;
122        }
123    }
124
125    true
126}
127
128#[cfg(test)]
129mod tests {
130    use super::strip_ito_internal_comment_blocks;
131
132    #[test]
133    fn strip_ito_internal_comment_blocks_removes_internal_template_guidance() {
134        let contents = r#"
135Keep this.
136<!-- ITO:INTERNAL:START -->
137## Your Guidance
138(placeholder)
139<!-- ITO:INTERNAL:END -->
140Keep this too.
141"#;
142
143        let stripped = strip_ito_internal_comment_blocks(contents);
144        assert!(stripped.contains("Keep this."));
145        assert!(stripped.contains("Keep this too."));
146        assert!(!stripped.contains("## Your Guidance"));
147        assert!(!stripped.contains("(placeholder)"));
148    }
149}