Skip to main content

ralph_workflow/prompts/
io.rs

1//! I/O and boundary module for prompts - contains imperative parsing code.
2//!
3//! This module satisfies the dylint boundary-module check for code that uses
4//! imperative patterns (while loops, mutable state, byte parsing).
5
6use std::io;
7use std::path::{Path, PathBuf};
8
9pub use crate::prompts::template_registry::TemplateError;
10pub use crate::prompts::template_validator::TemplateMetadata;
11pub use crate::prompts::template_validator::ValidationError;
12pub use crate::prompts::template_validator::VariableInfo;
13
14pub fn get_xdg_config_home() -> Option<PathBuf> {
15    std::env::var("XDG_CONFIG_HOME")
16        .ok()
17        .map(PathBuf::from)
18        .or_else(|| {
19            std::env::var("HOME")
20                .ok()
21                .map(|h| PathBuf::from(h).join(".config"))
22        })
23}
24
25pub fn template_exists(path: &Path) -> bool {
26    path.exists()
27}
28
29#[derive(Debug, thiserror::Error)]
30pub enum LoadTemplateError {
31    #[error("failed to read template from {path:?}")]
32    Io {
33        path: PathBuf,
34        #[source]
35        source: io::Error,
36    },
37}
38
39pub fn load_template(path: &Path) -> Result<String, LoadTemplateError> {
40    std::fs::read_to_string(path).map_err(|source| LoadTemplateError::Io {
41        path: path.to_path_buf(),
42        source,
43    })
44}
45
46pub fn validate_syntax(content: &str) -> Vec<ValidationError> {
47    let bytes = content.as_bytes();
48    let state = crate::prompts::template_parsing::validate_template_bytes(content, bytes);
49    state
50        .errors
51        .into_iter()
52        .map(|e| match e {
53            crate::prompts::template_parsing::ValidationError::UnclosedComment { line } => {
54                ValidationError::UnclosedComment { line }
55            }
56            crate::prompts::template_parsing::ValidationError::UnclosedConditional { line } => {
57                ValidationError::UnclosedConditional { line }
58            }
59            crate::prompts::template_parsing::ValidationError::UnclosedLoop { line } => {
60                ValidationError::UnclosedLoop { line }
61            }
62            crate::prompts::template_parsing::ValidationError::InvalidConditional {
63                line,
64                syntax,
65            } => ValidationError::InvalidConditional { line, syntax },
66            crate::prompts::template_parsing::ValidationError::InvalidLoop { line, syntax } => {
67                ValidationError::InvalidLoop { line, syntax }
68            }
69        })
70        .collect()
71}
72
73use crate::prompts::prompt_history_entry::PromptHistoryEntry;
74use serde::{Serialize, Serializer};
75
76impl Serialize for PromptHistoryEntry {
77    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
78        use serde::ser::SerializeStruct;
79        let mut s = serializer.serialize_struct(
80            "PromptHistoryEntry",
81            if self.content_id.is_some() { 2 } else { 1 },
82        )?;
83        s.serialize_field("content", &self.content)?;
84        if let Some(content_id) = &self.content_id {
85            s.serialize_field("content_id", content_id)?;
86        }
87        s.end()
88    }
89}
90
91fn parse_metadata_line(line: &str) -> Option<(Option<String>, Option<String>)> {
92    crate::prompts::template_parsing::parse_metadata_line_impl(line)
93}
94
95pub fn extract_variables(content: &str) -> Vec<VariableInfo> {
96    crate::prompts::template_parsing::extract_variables_impl(content)
97}
98
99pub fn extract_partials(content: &str) -> Vec<String> {
100    crate::prompts::template_parsing::extract_partials_impl(content)
101}
102
103fn update_metadata_from_line(
104    line: &str,
105    version: &mut Option<String>,
106    purpose: &mut Option<String>,
107) {
108    if !line.starts_with("{#") || !line.ends_with("#}") {
109        return;
110    }
111    if let Some((v, p)) = parse_metadata_line(line) {
112        *version = version.take().or(v);
113        *purpose = purpose.take().or(p);
114    }
115}
116
117pub fn extract_metadata(content: &str) -> TemplateMetadata {
118    let mut version = None;
119    let mut purpose = None;
120    for line in content.lines().take(50) {
121        update_metadata_from_line(line.trim(), &mut version, &mut purpose);
122    }
123    TemplateMetadata { version, purpose }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use std::io;
130    use std::time::{SystemTime, UNIX_EPOCH};
131
132    fn missing_template_path() -> PathBuf {
133        let now = SystemTime::now()
134            .duration_since(UNIX_EPOCH)
135            .expect("system time is after UNIX_EPOCH")
136            .as_nanos();
137        std::env::temp_dir().join(format!("load_template_missing_{now}"))
138    }
139
140    #[test]
141    fn load_template_missing_file_returns_not_found_error() {
142        let path = missing_template_path();
143        assert!(!path.exists(), "generated path should not already exist");
144
145        let err = load_template(&path).expect_err("expected missing file to return an error");
146        match err {
147            LoadTemplateError::Io {
148                path: err_path,
149                source,
150            } => {
151                assert_eq!(err_path, path);
152                assert_eq!(source.kind(), io::ErrorKind::NotFound);
153            }
154        }
155    }
156}