1use std::collections::HashMap;
18use std::io::Read;
19use std::path::{Component, Path, PathBuf};
20use std::sync::{LazyLock, Mutex};
21
22type Links = Vec<(String, PathBuf)>;
24
25static PROJECTS: LazyLock<Mutex<HashMap<PathBuf, Links>>> = LazyLock::new(Default::default);
27
28pub const LAUNCHER: &str = "runcss.sh";
30
31type Projects = Vec<(String, Links)>;
33
34static LAUNCHERS: LazyLock<Mutex<HashMap<PathBuf, Projects>>> = LazyLock::new(Default::default);
36
37pub fn locate(path: &Path) -> PathBuf {
41 locate_within(path, MAX_HOPS)
42}
43
44const MAX_HOPS: usize = 8;
47
48fn locate_within(path: &Path, hops: usize) -> PathBuf {
49 if path.exists() {
50 return path.to_path_buf();
51 }
52 let path = normalize(path);
53 let mut dir = path.parent();
54 while let Some(d) = dir {
55 if let Some(links) = links(d)
56 && let Ok(rest) = path.strip_prefix(d)
57 && let Some(Component::Normal(first)) = rest.components().next()
58 && let Some((_, target)) = links.iter().find(|(name, _)| first == name.as_str())
59 {
60 let below = rest.strip_prefix(first).unwrap_or(Path::new(""));
61 let real = target.join(below);
62 return if real.exists() || hops == 0 {
64 real
65 } else {
66 locate_within(&real, hops - 1)
67 };
68 }
69 dir = d.parent();
70 }
71 path
72}
73
74pub fn virtual_path(project_dir: &Path, real: &Path) -> Option<PathBuf> {
77 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
78 let targets: Vec<(String, PathBuf)> = links(project_dir)?
79 .into_iter()
80 .map(|(name, target)| (name, canonical(&target)))
81 .collect();
82 let find = |real: &Path| {
83 targets.iter().find_map(|(name, target)| {
84 let rest = real.strip_prefix(target).ok()?;
85 Some(project_dir.join(name).join(rest))
86 })
87 };
88 let dir = match real.parent() {
92 Some(d) if !d.as_os_str().is_empty() => d,
93 _ => Path::new("."),
94 };
95 real.file_name()
96 .and_then(|name| find(&canonical(dir).join(name)))
97 .or_else(|| find(&canonical(real)))
98}
99
100pub fn project_dir(dir: &Path) -> Option<PathBuf> {
105 let own = links(dir);
106 if own.as_ref().is_some_and(|l| !l.is_empty()) {
107 return Some(dir.to_path_buf());
108 }
109 let script = if dir.file_name().is_some_and(|n| n == LAUNCHER) {
110 dir.to_path_buf()
111 } else {
112 dir.join(LAUNCHER)
113 };
114 match launcher_projects(&script).into_iter().next() {
115 Some((project, _)) => Some(script.join(project)),
116 None => own.map(|_| dir.to_path_buf()),
117 }
118}
119
120const LAUNCHER_DEPTH: usize = 3;
123
124pub fn launcher_path(real: &Path) -> Option<PathBuf> {
132 if !real.is_file() {
133 return None;
134 }
135 let abs = normalize(&std::path::absolute(real).ok()?);
136 for dir in abs.ancestors().skip(1).take(LAUNCHER_DEPTH) {
137 if dir.join(".project").is_file() && virtual_path(dir, real).is_some() {
138 return None;
139 }
140 let script = dir.join(LAUNCHER);
141 if script.is_file() {
142 return launcher_projects(&script)
143 .iter()
144 .find_map(|(project, _)| virtual_path(&script.join(project), real));
145 }
146 }
147 None
148}
149
150pub fn is_project(dir: &Path) -> bool {
153 match launcher_of(dir) {
154 Some(_) => links(dir).is_some(),
155 None => dir.join(".project").is_file(),
156 }
157}
158
159pub fn through_launcher(path: &Path) -> bool {
161 path.ancestors().any(|d| launcher_of(d).is_some())
162}
163
164fn launcher_of(dir: &Path) -> Option<&Path> {
166 dir.parent()
167 .filter(|s| s.file_name() == Some(LAUNCHER.as_ref()))
168}
169
170pub fn forget() {
173 PROJECTS.lock().unwrap().clear();
174 LAUNCHERS.lock().unwrap().clear();
175}
176
177const MAX_PROJECT_SIZE: u64 = 1 << 20;
179
180fn read_capped(path: &Path) -> std::io::Result<String> {
182 let mut bytes = Vec::new();
183 std::fs::File::open(path)?
184 .take(MAX_PROJECT_SIZE + 1)
185 .read_to_end(&mut bytes)?;
186 if bytes.len() as u64 > MAX_PROJECT_SIZE {
187 log::warn!("{}: larger than 1 MiB, ignored", path.display());
188 return Err(std::io::Error::other("file too large"));
189 }
190 String::from_utf8(bytes).map_err(std::io::Error::other)
191}
192
193pub fn links(dir: &Path) -> Option<Links> {
196 if let Some(script) = launcher_of(dir) {
197 let project = dir.file_name()?;
198 return launcher_projects(script)
199 .into_iter()
200 .find(|(name, _)| project == name.as_str())
201 .map(|(_, links)| links);
202 }
203 let mut projects = PROJECTS.lock().unwrap();
204 if let Some(links) = projects.get(dir) {
205 return Some(links.clone());
206 }
207 let text = read_capped(&dir.join(".project")).ok()?;
208 let links = parse(&text, dir);
209 projects.insert(dir.to_path_buf(), links.clone());
210 Some(links)
211}
212
213fn launcher_projects(script: &Path) -> Projects {
216 let mut launchers = LAUNCHERS.lock().unwrap();
217 if let Some(projects) = launchers.get(script) {
218 return projects.clone();
219 }
220 if !script.is_file() {
222 return Vec::new();
223 }
224 let Some(dir) = script.parent() else {
225 return Vec::new();
226 };
227 let projects = read_capped(script)
228 .map(|text| parse_launcher(&text, dir))
229 .unwrap_or_default();
230 launchers.insert(script.to_path_buf(), projects.clone());
231 projects
232}
233
234const REDIRECT_TABLE: &str = "/dls_sw/prod/etc/redirector/redirect_table";
236
237fn parse_launcher(text: &str, dir: &Path) -> Projects {
243 parse_launcher_with(text, dir, Path::new(REDIRECT_TABLE))
244}
245
246fn parse_launcher_with(text: &str, dir: &Path, table: &Path) -> Projects {
247 let dir = normalize(&std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()));
248 let mut shell = Shell {
249 vars: HashMap::from([("SCRIPT_DIR".into(), dir.display().to_string())]),
250 functions: HashMap::new(),
251 table: table.to_path_buf(),
252 };
253 shell.set_top(&dir.join("../../.."));
254 shell.run(text, SOURCE_DEPTH);
255 let mut projects = Projects::new();
256 for entry in shell
257 .vars
258 .get("links")
259 .map_or("", String::as_str)
260 .split(',')
261 {
262 let Some((real, virt)) = entry.trim().split_once('=') else {
263 continue;
264 };
265 let Some((project, name)) = virt.trim_start_matches('/').split_once('/') else {
266 continue;
267 };
268 let name = name.trim_end_matches('/');
269 let real = normalize(Path::new(real));
270 if project.is_empty() || name.is_empty() || name.contains('/') || !real.is_absolute() {
271 continue;
272 }
273 let link = (name.to_string(), real);
274 match projects.iter_mut().find(|(p, _)| p == project) {
275 Some((_, links)) => links.push(link),
276 None => projects.push((project.to_string(), vec![link])),
277 }
278 }
279 projects
280}
281
282const SOURCE_DEPTH: usize = 4;
284
285const CALL_DEPTH: usize = 16;
287
288struct Shell {
294 vars: HashMap<String, String>,
295 functions: HashMap<String, String>,
297 table: PathBuf,
298}
299
300impl Shell {
301 fn set_top(&mut self, top: &Path) {
303 let version = std::fs::read_to_string(top.join("configure/VERSION"))
304 .map(|v| v.trim().to_string())
305 .unwrap_or_else(|_| "dev".into());
306 self.vars.insert("TOP".into(), top.display().to_string());
307 self.vars.insert("version".into(), version);
308 }
309
310 fn run(&mut self, text: &str, depth: usize) {
311 let text = text.replace("\\\n", "");
312 let mut lines = text.lines().map(str::trim);
313 while let Some(line) = lines.next() {
314 if let Some(name) = function_name(line) {
315 let mut body = Vec::new();
316 if !line.ends_with('{') && lines.next() != Some("{") {
317 continue;
318 }
319 for l in lines.by_ref() {
320 if l == "}" {
321 break;
322 }
323 if !l.is_empty() && !l.starts_with('#') {
324 body.push(l);
325 }
326 }
327 if let [echo] = body.as_slice()
328 && let Some(out) = echo.strip_prefix("echo ")
329 {
330 self.functions
331 .insert(name.to_string(), out.trim().to_string());
332 }
333 continue;
334 }
335 let source = line
336 .strip_prefix("source ")
337 .or_else(|| line.strip_prefix(". "));
338 if let Some(file) = source {
339 if depth > 0
340 && let Some(file) = self.expand(unquote(file.trim()))
341 && let Ok(text) = std::fs::read_to_string(&file)
342 {
343 self.run(&text, depth - 1);
344 }
345 continue;
346 }
347 let Some((name, value)) = line.split_once('=') else {
348 continue;
349 };
350 let plain = !name.is_empty()
351 && !name.starts_with(|c: char| c.is_ascii_digit())
352 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
353 let fixed = ["SCRIPT_DIR", "version"].contains(&name);
356 if !plain || fixed || value.contains('`') {
357 continue;
358 }
359 let value = match value.strip_prefix('\'') {
360 Some(v) => v.strip_suffix('\'').unwrap_or(v).to_string(),
361 None => match self.expand(unquote(value)) {
362 Some(v) => v,
363 None => continue,
364 },
365 };
366 if name == "TOP" {
367 self.set_top(&normalize(Path::new(&value)));
368 } else {
369 self.vars.insert(name.to_string(), value);
370 }
371 }
372 }
373
374 fn expand(&self, text: &str) -> Option<String> {
378 self.expand_with(text, &HashMap::new(), CALL_DEPTH)
379 }
380
381 fn expand_with(
383 &self,
384 text: &str,
385 args: &HashMap<String, String>,
386 depth: usize,
387 ) -> Option<String> {
388 let word = |c: char| c.is_ascii_alphanumeric() || c == '_';
389 let mut out = String::with_capacity(text.len());
390 let mut rest = text;
391 while let Some(i) = rest.find('$') {
392 out.push_str(&rest[..i]);
393 let after = &rest[i + 1..];
394 if let Some(inner) = after.strip_prefix('(') {
395 let end = closing_paren(inner)?;
396 out.push_str(self.command(&inner[..end], depth)?.trim_end_matches('\n'));
397 rest = &inner[end + 1..];
398 continue;
399 }
400 let (name, next) = match after.strip_prefix('{') {
401 Some(braced) => match braced.split_once('}') {
402 Some((name, next)) => (name, next),
403 None => ("", after),
404 },
405 None => {
406 let end = after.find(|c| !word(c)).unwrap_or(after.len());
407 (&after[..end], &after[end..])
408 }
409 };
410 if name.is_empty() {
411 out.push('$');
412 } else {
413 let value = args.get(name).or_else(|| self.vars.get(name));
414 out.push_str(value.map_or("", String::as_str));
415 }
416 rest = next;
417 }
418 out.push_str(rest);
419 Some(out)
420 }
421
422 fn command(&self, text: &str, depth: usize) -> Option<String> {
424 let depth = depth.checked_sub(1)?;
426 let words = split_words(text)
427 .into_iter()
428 .map(|w| self.expand_with(unquote(w), &HashMap::new(), depth))
429 .collect::<Option<Vec<_>>>()?;
430 let (name, args) = words.split_first()?;
431 match (name.as_str(), args) {
432 ("join", [sep, rest @ ..]) => Some(rest.join(sep)),
433 ("configure-ioc", [mode, flag, key])
434 if ["s", "show"].contains(&mode.as_str()) && flag == "-p" =>
435 {
436 let table = std::fs::read_to_string(&self.table).ok()?;
437 table.lines().find_map(|l| {
438 let (k, v) = l.split_once(' ')?;
439 (k == key).then(|| v.trim().to_string())
440 })
441 }
442 _ => {
443 let echo = self.functions.get(name)?;
444 let args = args
445 .iter()
446 .enumerate()
447 .map(|(i, a)| ((i + 1).to_string(), a.clone()))
448 .collect();
449 self.expand_with(unquote(echo), &args, depth)
450 }
451 }
452 }
453}
454
455fn function_name(line: &str) -> Option<&str> {
458 let line = line.strip_prefix("function ").unwrap_or(line);
459 let (name, rest) = line.split_once("()")?;
460 let name = name.trim();
461 let ok = !name.is_empty()
462 && name
463 .chars()
464 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
465 && matches!(rest.trim(), "" | "{");
466 ok.then_some(name)
467}
468
469fn unquote(text: &str) -> &str {
471 text.strip_prefix('"')
472 .and_then(|t| t.strip_suffix('"'))
473 .unwrap_or(text)
474}
475
476fn closing_paren(text: &str) -> Option<usize> {
478 let mut depth = 0;
479 for (i, c) in text.char_indices() {
480 match c {
481 '(' => depth += 1,
482 ')' if depth == 0 => return Some(i),
483 ')' => depth -= 1,
484 _ => {}
485 }
486 }
487 None
488}
489
490fn split_words(text: &str) -> Vec<&str> {
492 let mut words = Vec::new();
493 let (mut start, mut depth, mut quoted) = (None, 0, false);
494 for (i, c) in text.char_indices() {
495 match c {
496 '"' => quoted = !quoted,
497 '(' => depth += 1,
498 ')' => depth -= 1,
499 c if c.is_whitespace() && depth == 0 && !quoted => {
500 if let Some(s) = start.take() {
501 words.push(&text[s..i]);
502 }
503 continue;
504 }
505 _ => {}
506 }
507 start.get_or_insert(i);
508 }
509 if let Some(s) = start {
510 words.push(&text[s..]);
511 }
512 words
513}
514
515fn parse(text: &str, dir: &Path) -> Links {
518 if crate::element::depth(text) > crate::element::MAX_DEPTH {
519 return Vec::new();
520 }
521 let Ok(doc) = roxmltree::Document::parse_with_options(
522 text,
523 roxmltree::ParsingOptions {
524 nodes_limit: 1 << 16,
525 ..Default::default()
526 },
527 ) else {
528 return Vec::new();
529 };
530 doc.descendants()
531 .filter(|n| n.has_tag_name("link"))
532 .filter_map(|link| {
533 let child = |name: &str| {
534 link.children()
535 .find(|c| c.has_tag_name(name))
536 .and_then(|c| c.text())
537 .map(str::trim)
538 };
539 let name = child("name")?;
540 let location = child("location").or_else(|| child("locationURI"))?;
541 Some((name.to_string(), location_path(location, dir)?))
542 })
543 .collect()
544}
545
546fn location_path(location: &str, dir: &Path) -> Option<PathBuf> {
552 let project_relative = |rest: &str| {
553 let (n, rest) = match rest.strip_prefix("PARENT-") {
554 Some(r) => {
555 let (n, r) = r.split_once("-PROJECT_LOC")?;
556 (n.parse::<usize>().ok()?, r)
557 }
558 None => (0, rest.strip_prefix("PROJECT_LOC")?),
559 };
560 let rest = rest.strip_prefix('/').or(rest.is_empty().then_some(rest))?;
561 let mut base = normalize(&std::path::absolute(dir).ok()?);
563 for _ in 0..n {
564 base = base.parent()?.to_path_buf();
565 }
566 Some(base.join(percent_decode(rest)))
567 };
568 let path = if let Some(uri) = location.strip_prefix("file:") {
569 let path = match uri.strip_prefix("//") {
571 Some(rest) => &rest[rest.find('/')?..],
572 None => uri,
573 };
574 let path = percent_decode(path);
576 match path.to_str() {
577 Some(p) => PathBuf::from(crate::source::file_url_path(p)),
578 None => PathBuf::from(path),
579 }
580 } else if let Some(path) = project_relative(location) {
581 path
582 } else {
583 PathBuf::from(location)
584 };
585 path.is_absolute().then_some(path)
586}
587
588fn percent_decode(text: &str) -> std::ffi::OsString {
590 let bytes = text.as_bytes();
591 let mut out = Vec::with_capacity(bytes.len());
592 let mut i = 0;
593 while i < bytes.len() {
594 let hex = bytes
595 .get(i + 1..i + 3)
596 .filter(|h| bytes[i] == b'%' && h.iter().all(u8::is_ascii_hexdigit))
597 .and_then(|h| u8::from_str_radix(std::str::from_utf8(h).ok()?, 16).ok());
598 match hex {
599 Some(b) => {
600 out.push(b);
601 i += 3;
602 }
603 None => {
604 out.push(bytes[i]);
605 i += 1;
606 }
607 }
608 }
609 #[cfg(unix)]
610 return std::os::unix::ffi::OsStringExt::from_vec(out);
611 #[cfg(not(unix))]
614 return String::from_utf8_lossy(&out).into_owned().into();
615}
616
617pub fn normalize(path: &Path) -> PathBuf {
619 let mut out = PathBuf::new();
620 for c in path.components() {
621 match c {
622 Component::CurDir => {}
623 Component::ParentDir => match out.components().next_back() {
626 Some(Component::Normal(_)) => {
627 out.pop();
628 }
629 Some(Component::RootDir | Component::Prefix(_)) => {}
630 _ => out.push(".."),
631 },
632 c => out.push(c),
633 }
634 }
635 out
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 fn tmp(name: &str) -> PathBuf {
643 let dir = std::env::temp_dir().join(format!("bob-project-{name}-{}", std::process::id()));
644 let _ = std::fs::remove_dir_all(&dir);
645 std::fs::create_dir_all(&dir).unwrap();
646 dir
647 }
648
649 #[test]
650 fn finds_files_through_linked_folders() {
651 let real = tmp("real");
652 std::fs::write(real.join("x.opi"), "<display/>").unwrap();
653 let proj = tmp("proj");
654 std::fs::write(
655 proj.join(".project"),
656 format!(
657 "<projectDescription><linkedResources>
658 <link><name>a</name><type>2</type><location>{}</location></link>
659 <link><name>u</name><type>2</type><locationURI>file:{}</locationURI></link>
660 </linkedResources></projectDescription>",
661 real.display(),
662 real.display()
663 ),
664 )
665 .unwrap();
666 let x = real.join("x.opi");
667 assert_eq!(locate(&proj.join("a/x.opi")), x);
668 assert_eq!(locate(&proj.join("b/../a/x.opi")), x);
669 assert_eq!(locate(&proj.join("u/x.opi")), x);
670 assert_eq!(locate(&proj.join("a/./sub/../x.opi")), x);
671 assert_eq!(locate(&x), x);
673 assert_eq!(locate(&proj.join("c/../q.opi")), proj.join("q.opi"));
674 assert_eq!(virtual_path(&proj, &x), Some(proj.join("a/x.opi")));
675 assert_eq!(virtual_path(&proj, Path::new("/elsewhere/y.opi")), None);
676 }
677
678 #[test]
679 fn link_cycles_end() {
680 let proj = tmp("cycle");
681 std::fs::write(
682 proj.join(".project"),
683 format!(
684 "<projectDescription><linkedResources>
685 <link><name>a</name><location>{}</location></link>
686 </linkedResources></projectDescription>",
687 proj.join("a").display()
688 ),
689 )
690 .unwrap();
691 assert_eq!(locate(&proj.join("a/x.opi")), proj.join("a/x.opi"));
692 }
693
694 #[cfg(windows)]
695 #[test]
696 fn windows_link_locations() {
697 let loc = |l: &str| location_path(l, Path::new(r"C:\w\proj"));
698 assert_eq!(loc(r"D:\abs\opi"), Some(PathBuf::from(r"D:\abs\opi")));
699 assert_eq!(
700 loc("file:/D:/a%20b/opi"),
701 Some(PathBuf::from(r"D:\a b\opi"))
702 );
703 assert_eq!(loc("file:///D%3A/opi"), Some(PathBuf::from(r"D:\opi")));
704 assert_eq!(
705 loc("PROJECT_LOC/sub"),
706 Some(PathBuf::from(r"C:\w\proj\sub"))
707 );
708 assert_eq!(loc("/no/drive"), None);
709 }
710
711 #[cfg(unix)]
712 #[test]
713 fn link_locations_resolve_against_the_project() {
714 let dir = Path::new("/w/proj");
715 let loc = |l: &str| location_path(l, dir);
716 assert_eq!(loc("/abs/opi"), Some(PathBuf::from("/abs/opi")));
717 assert_eq!(loc("file:/a%20b/opi"), Some(PathBuf::from("/a b/opi")));
718 assert_eq!(loc("file:///a/opi"), Some(PathBuf::from("/a/opi")));
719 assert_eq!(loc("file://localhost/a"), Some(PathBuf::from("/a")));
720 assert_eq!(loc("PROJECT_LOC/sub"), Some(PathBuf::from("/w/proj/sub")));
721 assert_eq!(
722 loc("PARENT-1-PROJECT_LOC/other%20x"),
723 Some(PathBuf::from("/w/other x"))
724 );
725 let up = normalize(&std::path::absolute("..").unwrap());
727 assert_eq!(
728 location_path("PARENT-2-PROJECT_LOC/x", Path::new("../proj")),
729 Some(up.parent().unwrap().join("x"))
730 );
731 for bad in [
732 "virtual:/virtual",
733 "WORKSPACE_LOC/x",
734 "rel/dir",
735 "PARENT-9-PROJECT_LOC/x",
736 ] {
737 assert_eq!(loc(bad), None, "{bad}");
738 }
739 }
740
741 const RUNCSS: &str = r#"#!/bin/bash
743SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
744TOP=${SCRIPT_DIR}/../../..
745CSS_RUN_SCRIPT=$(configure-ioc s -p CSS-gui)
746opifile="$1"
747if [[ -z $opifile || $opifile == "-h" ]]; then
748 exit 1
749fi
750if [ -e "${TOP}/configure/VERSION" ] ; then
751 version="$(cat "${TOP}"/configure/VERSION)"
752else
753 version="dev"
754fi
755project=vac_${version}
756module=vac
757launch_opi=/${project}/${module}/$opifile
758
759links="${SCRIPT_DIR}=${project}/${module},\
760GAUGE=/${project}/gauge/,\
761/nowhere=/${project}/sub/dir,\
762relative/dir=/${project}/rel,\
763OTHER=/elsewhere/other"
764
765$CSS_RUN_SCRIPT -o "${launch_opi}" -s -l "$links" "$@"
766"#;
767
768 fn module(name: &str, version: Option<&str>) -> (PathBuf, PathBuf, PathBuf) {
771 let root = tmp(name);
772 let top = root.join("top");
773 let opi = top.join("vacApp/opi/opi");
774 let gauge = root.join("gauge/opi");
775 std::fs::create_dir_all(opi.join("common")).unwrap();
776 std::fs::create_dir_all(&gauge).unwrap();
777 if let Some(v) = version {
778 std::fs::create_dir_all(top.join("configure")).unwrap();
779 std::fs::write(top.join("configure/VERSION"), format!("{v}\n")).unwrap();
780 }
781 let script = RUNCSS
782 .replace("GAUGE", &gauge.display().to_string())
783 .replace("OTHER", &root.display().to_string());
784 std::fs::write(opi.join(LAUNCHER), script).unwrap();
785 for f in [
786 opi.join("x.opi"),
787 opi.join("common/c.opi"),
788 gauge.join("g.opi"),
789 ] {
790 std::fs::write(f, "<display/>").unwrap();
791 }
792 (root, opi, gauge)
793 }
794
795 #[test]
796 fn a_launcher_is_a_project() {
797 let (root, opi, gauge) = module("runcss", Some("1-2"));
798 let proj = opi.join("runcss.sh/vac_1-2");
799 let x = proj.join("vac/x.opi");
800 assert_eq!(launcher_path(&opi.join("x.opi")), Some(x.clone()));
801 assert_eq!(
802 launcher_path(&opi.join("common/c.opi")),
803 Some(proj.join("vac/common/c.opi"))
804 );
805 assert_eq!(launcher_path(&gauge.join("g.opi")), None);
806 assert_eq!(launcher_path(&x), None);
807 assert_eq!(locate(&x), opi.join("x.opi"));
809 assert_eq!(
810 locate(&proj.join("vac/../gauge/g.opi")),
811 gauge.join("g.opi")
812 );
813 assert_eq!(locate(&proj.join("vac/common/../x.opi")), opi.join("x.opi"));
814 let names: Vec<String> = links(&proj).unwrap().into_iter().map(|l| l.0).collect();
816 assert_eq!(names, ["vac", "gauge"]);
817 assert_eq!(
818 links(&opi.join("runcss.sh/elsewhere")).unwrap()[0],
819 ("other".to_string(), root.clone())
820 );
821 assert_eq!(links(&opi.join("runcss.sh/vac_dev")), None);
822 assert_eq!(project_dir(&opi), Some(proj.clone()));
824 assert_eq!(project_dir(&opi.join(LAUNCHER)), Some(proj.clone()));
825 assert_eq!(project_dir(&gauge), None);
826 assert_eq!(
827 virtual_path(&proj, &gauge.join("g.opi")),
828 Some(proj.join("gauge/g.opi"))
829 );
830 std::fs::remove_dir_all(&root).unwrap();
831 }
832
833 #[cfg(unix)]
834 #[test]
835 fn reads_a_generated_launcher() {
836 let text = include_str!("testdata/runcss.sh");
838 let dir = Path::new("/nowhere/vacuum/4-100/vacuumApp/opi/opi");
839 let projects = parse_launcher(text, dir);
840 let [(project, links)] = projects.as_slice() else {
841 panic!("{projects:?}")
842 };
843 assert_eq!(project, "vacuum_dev");
844 assert_eq!(links.len(), 14);
845 assert_eq!(links[0], ("vacuum".to_string(), dir.to_path_buf()));
846 let support = Path::new("/dls_sw/prod/R3.14.12.7/support");
847 assert_eq!(
848 links[2],
849 (
850 "mks937a".to_string(),
851 support.join("mks937a/2-90/mks937aApp/opi/opi")
852 )
853 );
854 assert_eq!(links[13].0, "devIocStats");
855 }
856
857 #[cfg(unix)]
858 #[test]
859 fn reads_a_launcher_that_sources_versions_and_calls_functions() {
860 let top = tmp("diag-runcss");
863 std::fs::create_dir_all(top.join("configure")).unwrap();
864 std::fs::write(top.join("configure/VERSION"), "2-90\n").unwrap();
865 std::fs::write(
866 top.join("opi-versions"),
867 include_str!("testdata/diag-opi-versions"),
868 )
869 .unwrap();
870 let table = top.join("redirect_table");
871 std::fs::write(
872 &table,
873 "CSS-gui /x/css.sh\nDI-EBPM-gui-dir /ioc/Libera/2-05-17\nDI-MBF-gui-dir /ioc/MBF/1\n",
874 )
875 .unwrap();
876 let text = include_str!("testdata/diag-runcss.sh");
877 let projects = parse_launcher_with(text, &top, &table);
878 let [(project, links)] = projects.as_slice() else {
879 panic!("{projects:?}")
880 };
881 assert_eq!(project, "diagOpi_2-90");
882 let support = Path::new("/dls_sw/prod/R3.14.12.3/support");
883 let names: Vec<&str> = links.iter().map(|l| l.0.as_str()).collect();
884 assert_eq!(
885 names,
886 [
887 "diagOpi",
888 "motor",
889 "devIocStats",
890 "enzLoCuM4",
891 "pmacUtil",
892 "TimingTemplates",
893 "4chTimer",
894 "dlsPLC",
895 "Libera",
896 "IsaPBPM",
897 "MBF",
898 ]
899 );
900 assert_eq!(links[0].1, normalize(&top));
901 assert_eq!(
902 links[2].1,
903 support.join("devIocStats/3-1-14dls2-0/devIocStatsApp/opi/opi")
904 );
905 assert_eq!(links[8].1, Path::new("/ioc/Libera/2-05-17/opi"));
906 assert_eq!(links[9].1, Path::new("/opi"));
908 assert_eq!(links[10].1, Path::new("/ioc/MBF/1/opi"));
909 std::fs::remove_dir_all(&top).unwrap();
910 }
911
912 #[test]
913 fn only_boy_screens_go_through_a_launcher() {
914 let (root, opi, _) = module("runcss-bob", Some("1-2"));
915 let boy = r#"<display typeId="org.csstudio.opibuilder.Display" version="1.0.0"/>"#;
916 std::fs::write(opi.join("x.opi"), boy).unwrap();
917 std::fs::write(opi.join("y.bob"), r#"<display version="2.0.0"/>"#).unwrap();
918 let source = |p: &Path| crate::Display::load(p).unwrap().source;
919 let x = opi.join("runcss.sh/vac_1-2/vac/x.opi");
920 assert_eq!(source(&opi.join("x.opi")), Some(crate::Source::File(x)));
921 let y = opi.join("y.bob");
922 assert_eq!(source(&y), Some(crate::Source::File(y.clone())));
923 std::fs::create_dir_all(opi.join("sub")).unwrap();
926 let z = opi.join("sub/z.bob");
927 std::fs::write(&z, r#"<display version="2.0.0"/>"#).unwrap();
928 let d = crate::Display::load(&opi.join("runcss.sh/vac_1-2/vac/sub/z.bob")).unwrap();
929 assert_eq!(d.source, Some(crate::Source::File(z)));
930 let crate::Source::File(up) = d.resolve("../y.bob") else {
931 panic!("not a file")
932 };
933 assert_eq!(normalize(&up), y);
934 assert!(up.exists());
935 std::fs::remove_dir_all(&root).unwrap();
936 }
937
938 #[cfg(unix)]
939 #[test]
940 fn symlinked_screens_map_by_name_or_by_target() {
941 let (root, opi, _) = module("runcss-links", Some("1-2"));
942 let proj = opi.join("runcss.sh/vac_1-2");
943 std::os::unix::fs::symlink("x.opi", opi.join("y.opi")).unwrap();
945 assert_eq!(
946 launcher_path(&opi.join("y.opi")),
947 Some(proj.join("vac/y.opi"))
948 );
949 let home = root.join("home");
951 std::fs::create_dir_all(&home).unwrap();
952 std::os::unix::fs::symlink(opi.join("x.opi"), home.join("z.opi")).unwrap();
953 assert_eq!(
954 virtual_path(&proj, &home.join("z.opi")),
955 Some(proj.join("vac/x.opi"))
956 );
957 std::fs::remove_dir_all(&root).unwrap();
958 }
959
960 #[test]
961 fn a_project_file_beside_a_launcher_wins_only_with_links() {
962 let (root, opi, gauge) = module("runcss-empty", Some("1-2"));
963 let proj = opi.join("runcss.sh/vac_1-2");
964 std::fs::write(opi.join(".project"), "<projectDescription/>").unwrap();
966 assert_eq!(
967 launcher_path(&opi.join("x.opi")),
968 Some(proj.join("vac/x.opi"))
969 );
970 assert_eq!(project_dir(&opi), Some(proj));
971 let linking = format!(
973 "<projectDescription><linkedResources>
974 <link><name>me</name><location>{}</location></link>
975 </linkedResources></projectDescription>",
976 opi.display()
977 );
978 std::fs::write(opi.join(".project"), linking).unwrap();
979 forget();
980 assert_eq!(launcher_path(&opi.join("x.opi")), None);
981 assert_eq!(project_dir(&opi), Some(opi.clone()));
982 std::fs::write(gauge.join(".project"), "<projectDescription/>").unwrap();
984 assert_eq!(project_dir(&gauge), Some(gauge.clone()));
985 std::fs::remove_dir_all(&root).unwrap();
986 }
987
988 #[test]
989 fn the_launcher_is_near_the_screen() {
990 let (root, opi, _) = module("runcss-deep", Some("1-2"));
991 let deep = opi.join("a/b/c");
992 std::fs::create_dir_all(&deep).unwrap();
993 std::fs::write(deep.join("d.opi"), "<display/>").unwrap();
994 std::fs::write(opi.join("a/b/e.opi"), "<display/>").unwrap();
995 assert_eq!(launcher_path(&deep.join("d.opi")), None);
996 assert_eq!(
997 launcher_path(&opi.join("a/b/e.opi")),
998 Some(opi.join("runcss.sh/vac_1-2/vac/a/b/e.opi"))
999 );
1000 std::fs::remove_dir_all(&root).unwrap();
1001 }
1002
1003 #[test]
1004 fn forget_reads_launchers_again() {
1005 let (root, opi, _) = module("runcss-forget", Some("1-2"));
1006 assert!(project_dir(&opi).is_some());
1007 let text = std::fs::read_to_string(opi.join(LAUNCHER)).unwrap();
1008 std::fs::write(
1009 opi.join(LAUNCHER),
1010 text.replace("project=vac_", "project=new_"),
1011 )
1012 .unwrap();
1013 forget();
1015 assert_eq!(project_dir(&opi), Some(opi.join("runcss.sh/new_1-2")));
1016 std::fs::remove_dir_all(&root).unwrap();
1017 }
1018
1019 #[test]
1020 fn a_launcher_without_a_version_is_dev() {
1021 let (root, opi, _) = module("runcss-dev", None);
1022 assert_eq!(
1023 launcher_path(&opi.join("x.opi")),
1024 Some(opi.join("runcss.sh/vac_dev/vac/x.opi"))
1025 );
1026 std::fs::remove_dir_all(&root).unwrap();
1027 }
1028
1029 #[test]
1030 fn a_nearer_project_file_wins_over_a_launcher() {
1031 let (root, opi, _) = module("runcss-project", Some("1-2"));
1032 let common = opi.join("common");
1033 std::fs::write(common.join(".project"), "<projectDescription/>").unwrap();
1035 assert!(launcher_path(&common.join("c.opi")).is_some());
1036 let linking = format!(
1037 "<projectDescription><linkedResources>
1038 <link><name>me</name><location>{}</location></link>
1039 </linkedResources></projectDescription>",
1040 common.display()
1041 );
1042 std::fs::write(common.join(".project"), linking).unwrap();
1043 forget();
1044 assert_eq!(launcher_path(&common.join("c.opi")), None);
1045 std::fs::write(root.join(".project"), "<projectDescription/>").unwrap();
1047 assert!(launcher_path(&opi.join("x.opi")).is_some());
1048 std::fs::remove_dir_all(&root).unwrap();
1049 }
1050
1051 #[cfg(unix)]
1052 #[test]
1053 fn a_function_calling_itself_ends() {
1054 let text = "f()\n{\n echo \"$(f)\"\n}\nx=$(f)\nlinks=\"/a=/p/n\"\n";
1055 let projects = parse_launcher_with(text, Path::new("/nowhere"), Path::new("/no/table"));
1056 assert_eq!(projects.len(), 1);
1057 }
1058
1059 #[test]
1060 fn expands_shell_variables() {
1061 let shell = Shell {
1062 vars: HashMap::from([("a".to_string(), "1".to_string())]),
1063 functions: HashMap::new(),
1064 table: PathBuf::new(),
1065 };
1066 let e = |t: &str| shell.expand(t).unwrap();
1067 assert_eq!(e("x${a}y$a/$b.$"), "x1y1/.$");
1068 assert_eq!(e("${a"), "${a");
1069 }
1070
1071 #[test]
1072 fn normalizes_lexically() {
1073 assert_eq!(
1074 normalize(Path::new("/a/b/../c/./d")),
1075 PathBuf::from("/a/c/d")
1076 );
1077 assert_eq!(normalize(Path::new("../x")), PathBuf::from("../x"));
1078 assert_eq!(normalize(Path::new("../../x")), PathBuf::from("../../x"));
1079 assert_eq!(normalize(Path::new("a/../../x")), PathBuf::from("../x"));
1080 assert_eq!(normalize(Path::new("/../x")), PathBuf::from("/x"));
1081 }
1082
1083 #[test]
1084 fn oversized_project_files_are_ignored_not_cached_empty() {
1085 let dir = std::env::temp_dir().join(format!("bobview-bigproj-{}", std::process::id()));
1086 std::fs::create_dir_all(&dir).unwrap();
1087 let link = format!(
1088 "<link><name>a</name><location>{}</location></link>",
1089 std::env::temp_dir().display()
1090 );
1091 let pad = " ".repeat(MAX_PROJECT_SIZE as usize);
1092 std::fs::write(dir.join(".project"), format!("<p>{pad}{link}</p>")).unwrap();
1093 assert!(links(&dir).is_none());
1094 std::fs::write(dir.join(".project"), format!("<p>{link}</p>")).unwrap();
1095 assert_eq!(links(&dir).map(|l| l.len()), Some(1));
1096 std::fs::remove_dir_all(&dir).ok();
1097 }
1098}