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
9pub 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 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 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 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 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 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}