Skip to main content

yt/fields/
cache.rs

1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use crate::api::Client;
5use crate::error::Result;
6use crate::fields::schema::{self, ProjectSchema};
7use crate::instance::InstanceUrl;
8
9/// Project schemas cached under `<cache>/<profile>/schema/<project>.json` for `cache_ttl`.
10pub struct SchemaCache {
11    dir: PathBuf,
12    ttl: Duration,
13}
14
15impl SchemaCache {
16    pub fn new(cache_dir: &Path, profile: &str, ttl: Duration) -> Self {
17        let profile: String = profile
18            .chars()
19            .map(|c| {
20                if c.is_alphanumeric() || c == '-' || c == '_' {
21                    c
22                } else {
23                    '_'
24                }
25            })
26            .collect();
27        Self {
28            dir: cache_dir.join(profile).join("schema"),
29            ttl,
30        }
31    }
32
33    /// The cache of one profile on one instance: profiles from `YOUTRACK_URL` share a name, not an instance.
34    pub fn for_profile(
35        cache_dir: &Path,
36        profile: &str,
37        instance: &InstanceUrl,
38        ttl: Duration,
39    ) -> Self {
40        Self::new(cache_dir, &instance.account(profile), ttl)
41    }
42
43    /// None for anything but a project short name, so a name can never point outside the cache.
44    fn path(&self, project: &str) -> Option<PathBuf> {
45        schema::check_short_name(project).ok()?;
46        Some(self.dir.join(format!("{project}.json")))
47    }
48
49    /// The cached schema while it is younger than the TTL.
50    pub fn load(&self, project: &str) -> Option<ProjectSchema> {
51        let path = self.path(project)?;
52        let age = std::fs::metadata(&path)
53            .ok()?
54            .modified()
55            .ok()?
56            .elapsed()
57            .ok()?;
58        if age >= self.ttl {
59            return None;
60        }
61        serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
62    }
63
64    /// Best effort: a schema that cannot be cached is fetched again next time.
65    pub fn store(&self, schema: &ProjectSchema) {
66        let Some(path) = self.path(&schema.project) else {
67            return;
68        };
69        let write = || -> std::io::Result<()> {
70            std::fs::create_dir_all(&self.dir)?;
71            std::fs::write(path, serde_json::to_vec(schema)?)
72        };
73        if let Err(error) = write() {
74            log::warn!("could not cache the fields of {}: {error}", schema.project);
75        }
76    }
77
78    /// Fetches the schema and caches it.
79    pub async fn refresh(&self, client: &Client, project: &str) -> Result<ProjectSchema> {
80        let schema = schema::fetch(client, project).await?;
81        self.store(&schema);
82        Ok(schema)
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use serde_json::json;
89    use wiremock::matchers::{method, path};
90    use wiremock::{Mock, MockServer, ResponseTemplate};
91
92    use super::*;
93    use crate::api::test_client;
94
95    fn schema() -> ProjectSchema {
96        ProjectSchema {
97            project: "WEB".into(),
98            fields: vec![],
99        }
100    }
101
102    #[test]
103    fn stored_schemas_load_until_they_expire() {
104        let dir = tempfile::tempdir().unwrap();
105        let cache = SchemaCache::new(dir.path(), "acme", Duration::from_secs(3600));
106        assert_eq!(cache.load("WEB"), None);
107        cache.store(&schema());
108        assert_eq!(cache.load("WEB"), Some(schema()));
109        assert!(dir.path().join("acme/schema/WEB.json").exists());
110        let expired = SchemaCache::new(dir.path(), "acme", Duration::ZERO);
111        assert_eq!(expired.load("WEB"), None);
112    }
113
114    #[test]
115    fn each_instance_gets_its_own_directory() {
116        let dir = tempfile::tempdir().unwrap();
117        let first = crate::instance::InstanceUrl::parse("https://acme.youtrack.cloud").unwrap();
118        let second =
119            crate::instance::InstanceUrl::parse("https://other.example.com/youtrack").unwrap();
120        SchemaCache::for_profile(dir.path(), "YOUTRACK_URL", &first, Duration::from_secs(60))
121            .store(&schema());
122        let other =
123            SchemaCache::for_profile(dir.path(), "YOUTRACK_URL", &second, Duration::from_secs(60));
124        assert_eq!(other.load("WEB"), None);
125        other.store(&schema());
126        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2);
127    }
128
129    #[test]
130    fn only_project_short_names_reach_the_disk() {
131        let dir = tempfile::tempdir().unwrap();
132        let cache = SchemaCache::new(dir.path(), "acme", Duration::from_secs(3600));
133        cache.store(&schema());
134        let outside = dir.path().join("planted");
135        std::fs::copy(
136            dir.path().join("acme/schema/WEB.json"),
137            outside.with_extension("json"),
138        )
139        .unwrap();
140        assert_eq!(cache.load(outside.to_str().unwrap()), None);
141        assert_eq!(cache.load("../../planted"), None);
142        cache.store(&ProjectSchema {
143            project: "../escaped".into(),
144            fields: vec![],
145        });
146        assert!(!dir.path().join("acme/escaped.json").exists());
147    }
148
149    #[test]
150    fn profile_names_become_safe_directory_names() {
151        let dir = tempfile::tempdir().unwrap();
152        SchemaCache::new(dir.path(), "a/b", Duration::from_secs(60)).store(&schema());
153        assert!(dir.path().join("a_b/schema/WEB.json").exists());
154    }
155
156    #[tokio::test]
157    async fn refresh_fetches_and_stores() {
158        let server = MockServer::start().await;
159        Mock::given(method("GET"))
160            .and(path("/api/admin/projects/WEB/customFields"))
161            .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
162            .mount(&server)
163            .await;
164        Mock::given(method("GET"))
165            .and(path("/api/issues"))
166            .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
167            .mount(&server)
168            .await;
169        let dir = tempfile::tempdir().unwrap();
170        let cache = SchemaCache::new(dir.path(), "acme", Duration::from_secs(60));
171        let fetched = cache
172            .refresh(&test_client(&server.uri()), "WEB")
173            .await
174            .unwrap();
175        assert_eq!(fetched, schema());
176        assert_eq!(cache.load("WEB"), Some(schema()));
177    }
178}