1use std::collections::HashSet;
4use std::path::Path;
5use std::sync::Mutex;
6
7use crate::runtimes::Catalog;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
10pub struct RuntimeId(&'static str);
11
12impl RuntimeId {
13 pub fn new(id: &str) -> RuntimeId {
14 static IDS: Mutex<Option<HashSet<&'static str>>> = Mutex::new(None);
15 let mut ids = IDS.lock().expect("runtime ids");
16 let ids = ids.get_or_insert_with(HashSet::new);
17 if let Some(have) = ids.get(id) {
18 return RuntimeId(have);
19 }
20 let leaked: &'static str = Box::leak(id.to_string().into_boxed_str());
21 ids.insert(leaked);
22 RuntimeId(leaked)
23 }
24
25 pub fn parse(s: &str) -> Option<RuntimeId> {
26 let s = s.trim();
27 let (base, install) = match s.split_once('@') {
28 Some((b, i)) => (b, Some(i)),
29 None => (s, None),
30 };
31 (valid(base) && install.is_none_or(valid_install)).then(|| RuntimeId::new(s))
32 }
33
34 pub fn name(self) -> &'static str {
35 self.0
36 }
37
38 pub fn base(self) -> RuntimeId {
39 match self.0.split_once('@') {
40 Some((b, _)) => RuntimeId::new(b),
41 None => self,
42 }
43 }
44
45 pub fn install(self) -> Option<&'static str> {
46 self.0.split_once('@').map(|(_, i)| i)
47 }
48}
49
50impl std::fmt::Display for RuntimeId {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.write_str(self.0)
53 }
54}
55
56pub fn valid(s: &str) -> bool {
57 superfluid_engine::artifact::valid_id(s)
58}
59
60pub fn valid_install(s: &str) -> bool {
61 !s.is_empty() && !s.starts_with('.') && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
62}
63
64#[derive(Debug, Clone, Default)]
65pub struct RuntimePick {
66 global: Option<String>,
67 per_model: Vec<(String, String)>,
68}
69
70impl RuntimePick {
71 pub fn add(&mut self, value: &str) -> Result<(), String> {
72 let named = |id: &str| -> Result<String, String> {
73 let id = id.trim();
74 if RuntimeId::parse(id).is_some() {
75 Ok(id.to_string())
76 } else {
77 Err(format!(
78 "--runtime '{id}' is not a runtime id (lowercase letters, digits, '.', '-', '_'), with an install after '@' if one"
79 ))
80 }
81 };
82 match value.split_once('=') {
83 Some((model, id)) => {
84 let model = model.trim();
85 if model.is_empty() {
86 return Err("--runtime <model>=<id> names no model".to_string());
87 }
88 let id = named(id)?;
89 self.per_model.retain(|(m, _)| m != model);
90 self.per_model.push((model.to_string(), id));
91 Ok(())
92 }
93 None if value.trim() == "auto" => {
94 self.global = None;
95 Ok(())
96 }
97 None => {
98 self.global = Some(named(value)?);
99 Ok(())
100 }
101 }
102 }
103
104 pub fn global(&self) -> Option<&str> {
105 self.global.as_deref()
106 }
107
108 pub fn explicit(&self, path: &Path) -> Option<&str> {
109 let written = path.to_string_lossy();
110 let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned());
111 let id = crate::registry::model_id_for(path);
112 let by_path = self
113 .per_model
114 .iter()
115 .rev()
116 .find(|(m, _)| *m == written || Path::new(m) == path);
117 let by_name = self.per_model.iter().rev().find(|(m, _)| Some(m) == stem.as_ref() || *m == id);
118 match by_path.or(by_name) {
119 Some((_, runtime)) => Some(runtime.as_str()).filter(|r| *r != "auto"),
120 None => self.global.as_deref(),
121 }
122 }
123
124 pub fn resolve(&self, path: &Path, catalog: &Catalog) -> Result<RuntimeId, String> {
125 self.resolve_with(path, None, catalog)
126 }
127
128 pub fn resolve_with(&self, path: &Path, requested: Option<RuntimeId>, catalog: &Catalog) -> Result<RuntimeId, String> {
129 let picked = match requested {
130 Some(id) => Some(id),
131 None => match self.explicit(path) {
132 Some(name) => Some(catalog.named(name).map_err(|why| catalog.not_here(name, path, &why))?),
133 None => None,
134 },
135 };
136 match picked {
137 Some(id) => {
138 catalog.reads(id, path)?;
139 Ok(id)
140 }
141 None => catalog.reader_of(path),
142 }
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn ids_are_interned_and_spelled_as_directories_can_be() {
152 let a = RuntimeId::new("llamacpp");
153 let b = RuntimeId::parse(" llamacpp ").unwrap();
154 assert_eq!(a, b);
155 assert!(std::ptr::eq(a.name(), b.name()), "one copy per id");
156 assert_eq!(a.to_string(), "llamacpp");
157 for bad in ["", "LlamaCpp", "a/b", "..", ".hidden", "sp ace"] {
158 assert_eq!(RuntimeId::parse(bad), None, "{bad:?}");
159 }
160 assert!(valid("llama.cpp") && valid("vllm") && valid("basert-2"));
161 let at = RuntimeId::parse("llamacpp@b11284-cuda-13.4").unwrap();
162 assert_eq!((at.base(), at.install()), (RuntimeId::new("llamacpp"), Some("b11284-cuda-13.4")));
163 assert_eq!((a.base(), a.install()), (a, None));
164 for bad in ["llamacpp@", "llamacpp@../x", "llamacpp@.hidden", "LL@b1"] {
165 assert_eq!(RuntimeId::parse(bad), None, "{bad:?}");
166 }
167 }
168
169 #[test]
170 fn per_model_picks_win_over_the_global_one_and_match_by_stem_or_path() {
171 let dir = std::env::temp_dir().join(format!("superfluid-runtime-pick-{}", std::process::id()));
172 std::fs::create_dir_all(dir.join("a")).unwrap();
173 std::fs::create_dir_all(dir.join("b")).unwrap();
174 let mine = dir.join("mine.gguf");
175 let other = dir.join("other.gguf");
176 let mut pick = RuntimePick::default();
177 pick.add("basert").unwrap();
178 pick.add("mine=llamacpp").unwrap();
179 assert_eq!(pick.explicit(&mine), Some("llamacpp"));
180 assert_eq!(pick.explicit(&other), Some("basert"));
181 let mut pick = RuntimePick::default();
182 pick.add(&format!("{}=llamacpp", other.display())).unwrap();
183 assert_eq!(pick.explicit(&other), Some("llamacpp"));
184 assert_eq!(pick.explicit(&mine), None);
185 let bundle = dir.join("a").join("model.base");
186 let gguf = dir.join("b").join("model.GGUF");
187 let mut pick = RuntimePick::default();
188 pick.add("model=basert").unwrap();
189 pick.add(&format!("{}=llamacpp", gguf.display())).unwrap();
190 assert_eq!(pick.explicit(&bundle), Some("basert"));
191 assert_eq!(pick.explicit(&gguf), Some("llamacpp"));
192 let mut pick = RuntimePick::default();
193 pick.add("mine=llamacpp").unwrap();
194 pick.add("mine=basert").unwrap();
195 assert_eq!(pick.explicit(&mine), Some("basert"));
196 let mut pick = RuntimePick::default();
197 pick.add("basert").unwrap();
198 pick.add("mine=auto").unwrap();
199 assert_eq!(pick.explicit(&mine), None);
200 assert_eq!(pick.explicit(&other), Some("basert"));
201 }
202
203 #[test]
204 fn bad_flag_values_are_named() {
205 let mut pick = RuntimePick::default();
206 let e = pick.add("Llama CPP").unwrap_err();
207 assert!(e.contains("'Llama CPP' is not a runtime id"), "{e}");
208 let e = pick.add("=llamacpp").unwrap_err();
209 assert!(e.contains("names no model"), "{e}");
210 let e = pick.add("x=No/pe").unwrap_err();
211 assert!(e.contains("'No/pe' is not a runtime id"), "{e}");
212 pick.add("vllm").unwrap();
213 assert_eq!(pick.global(), Some("vllm"));
214 pick.add("auto").unwrap();
215 assert_eq!(pick.global(), None);
216 }
217}