1use anyhow::{Context, Result};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use crate::paths::{self, DEFAULT_BUILD_DIR};
13
14pub fn cache_root() -> Result<PathBuf> {
18 let root = trusted_cache_root()?;
19 crate::util::ensure_windows_user_path(&root, "cache root")?;
20 Ok(root)
21}
22
23pub fn trusted_cache_root() -> Result<PathBuf> {
27 let value = paths::cargo_build_dir()?.unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string());
28 paths::resolve_template(&value)
29}
30
31fn validate_config_value(value: &str, what: &str) -> Result<()> {
34 if value.contains(['\n', '\r', '\0', '\'']) {
35 anyhow::bail!("{what} contains a forbidden character (newline, NUL or single quote)");
36 }
37 Ok(())
38}
39
40pub fn cargo_config_path() -> Result<PathBuf> {
43 let cargo_home = paths::cargo_home()?;
44 if cargo_home.is_relative() {
45 anyhow::bail!(
46 "CARGO_HOME is relative ({}) — set an absolute CARGO_HOME",
47 cargo_home.display()
48 );
49 }
50 match fs::symlink_metadata(&cargo_home) {
55 Ok(meta) => {
56 if !meta.is_dir() || meta.file_type().is_symlink() {
57 anyhow::bail!(
58 "CARGO_HOME {} is a symlink or not a directory",
59 cargo_home.display()
60 );
61 }
62 if !crate::util::owned_by_us(&meta) {
63 anyhow::bail!(
64 "CARGO_HOME {} is owned by another user",
65 cargo_home.display()
66 );
67 }
68 #[cfg(unix)]
69 {
70 use std::os::unix::fs::MetadataExt;
71 if meta.mode() & 0o022 != 0 {
72 anyhow::bail!(
73 "CARGO_HOME {} is group/world-writable (mode {:o})",
74 cargo_home.display(),
75 meta.mode() & 0o777
76 );
77 }
78 }
79 }
80 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
81 Err(err) => {
82 return Err(err).with_context(|| format!("inspecting {}", cargo_home.display()));
83 }
84 }
85 let path = cargo_home.join("config.toml");
86 if path.is_relative() {
87 anyhow::bail!(
88 "global cargo config path is relative ({}) — set an absolute CARGO_HOME",
89 path.display()
90 );
91 }
92 crate::util::ensure_windows_user_path(&path, "global cargo config")?;
93 match fs::symlink_metadata(&path) {
94 Ok(meta) => {
95 if meta.file_type().is_symlink() || !meta.is_file() {
96 anyhow::bail!(
97 "global cargo config {} is a symlink or not a regular file",
98 path.display()
99 );
100 }
101 if !crate::util::owned_by_us(&meta) {
102 anyhow::bail!(
103 "global cargo config {} is owned by another user",
104 path.display()
105 );
106 }
107 #[cfg(unix)]
108 {
109 use std::os::unix::fs::MetadataExt;
110 if meta.mode() & 0o022 != 0 {
111 anyhow::bail!(
112 "global cargo config {} is group/world-writable (mode {:o})",
113 path.display(),
114 meta.mode() & 0o777
115 );
116 }
117 }
118 }
119 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
120 Err(err) => {
121 return Err(err).with_context(|| format!("inspecting {}", path.display()));
122 }
123 }
124 Ok(path)
125}
126
127#[derive(Debug, PartialEq, Eq)]
129pub enum CargoEdit {
130 Created,
132 Inserted,
134 Replaced,
136 Removed,
138 AlreadySet,
140 Appended,
142 Reverted,
144}
145
146impl CargoEdit {
147 pub fn describe(&self) -> &'static str {
148 match self {
149 CargoEdit::Created => "created the file",
150 CargoEdit::Inserted => "added",
151 CargoEdit::Replaced => "updated",
152 CargoEdit::Removed => "removed",
153 CargoEdit::AlreadySet => "already set, unchanged",
154 CargoEdit::Appended => "appended line at the EOF",
155 CargoEdit::Reverted => "config file reverted to last backup",
156 }
157 }
158}
159
160fn set_build_key(text: &str, key: &str, value: &str) -> (String, CargoEdit) {
163 let wanted = format!("{key} = '{value}'");
164 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
165
166 let header = lines
169 .iter()
170 .position(|l| l.trim().replace([' ', '\t'], "") == "[build]");
171
172 match header {
173 Some(i) => {
174 let mut j = i + 1;
176 while j < lines.len() {
177 let t = lines[j].trim();
178 if t.starts_with('[') && t.ends_with(']') {
179 break;
180 }
181 if t.split_once('=').map_or(false, |(k, _)| k.trim() == key) {
182 if t == wanted {
183 return (text.to_string(), CargoEdit::AlreadySet);
184 }
185 lines[j] = wanted;
186 return (lines.join("\n") + "\n", CargoEdit::Replaced);
187 }
188 j += 1;
189 }
190 lines.insert(i + 1, wanted);
191 (lines.join("\n") + "\n", CargoEdit::Inserted)
192 }
193 None => {
194 let needs_blank = lines.last().map_or(false, |l| !l.trim().is_empty());
195 if needs_blank {
196 lines.push(String::new());
197 }
198 lines.push("[build]".to_string());
199 lines.push(wanted);
200 (lines.join("\n") + "\n", CargoEdit::Appended)
201 }
202 }
203}
204
205fn remove_build_key(text: &str, key: &str) -> (String, CargoEdit) {
207 let mut out: Vec<String> = Vec::new();
208 let mut in_build = false;
209 let mut removed = false;
210 for line in text.lines() {
211 let trimmed = line.trim();
212 if trimmed.starts_with('[') && trimmed.ends_with(']') {
213 in_build = trimmed.replace([' ', '\t'], "") == "[build]";
214 out.push(line.to_string());
215 continue;
216 }
217 if in_build
218 && trimmed
219 .split_once('=')
220 .map_or(false, |(candidate, _)| candidate.trim() == key)
221 {
222 removed = true;
223 continue;
224 }
225 out.push(line.to_string());
226 }
227 if removed {
228 (out.join("\n") + "\n", CargoEdit::Removed)
229 } else {
230 (text.to_string(), CargoEdit::AlreadySet)
231 }
232}
233fn read_build_key(text: &str, key: &str) -> Option<String> {
235 read_table_key(text, "build", key)
236}
237
238fn is_target_header(line: &str, target: &str) -> bool {
240 let compact = line.trim().replace([' ', '\t'], "");
241 compact == format!("[target.{target}]")
242 || compact == format!("[target.\"{target}\"]")
243 || compact == format!("[target.'{target}']")
244}
245
246fn set_target_rustflags(text: &str, target: &str, literal: &str) -> (String, CargoEdit, bool) {
250 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
251 if let Some(header) = lines.iter().position(|line| is_target_header(line, target)) {
252 let mut end = header + 1;
253 while end < lines.len() {
254 let trimmed = lines[end].trim();
255 if trimmed.starts_with('[') && trimmed.ends_with(']') {
256 break;
257 }
258 if let Some((key, value)) = trimmed.split_once('=') {
259 if key.trim() == "rustflags" {
260 let same = clean_toml_value(value) == literal;
261 return (text.to_string(), CargoEdit::AlreadySet, !same);
262 }
263 }
264 end += 1;
265 }
266 lines.insert(end, format!("rustflags = {literal}"));
267 (lines.join("\n") + "\n", CargoEdit::Inserted, false)
268 } else {
269 let needs_blank = lines.last().map_or(false, |line| !line.trim().is_empty());
270 if needs_blank {
271 lines.push(String::new());
272 }
273 lines.push(format!("[target.{target}]"));
274 lines.push(format!("rustflags = {literal}"));
275 (lines.join("\n") + "\n", CargoEdit::Appended, false)
276 }
277}
278
279fn remove_target_rustflags(text: &str, target: &str, literal: &str) -> (String, CargoEdit, bool) {
282 let mut out: Vec<String> = Vec::new();
283 let mut in_table = false;
284 let mut removed = false;
285 let mut conflict = false;
286 for line in text.lines() {
287 let trimmed = line.trim();
288 if trimmed.starts_with('[') && trimmed.ends_with(']') {
289 in_table = is_target_header(line, target);
290 out.push(line.to_string());
291 continue;
292 }
293 if in_table {
294 if let Some((key, value)) = trimmed.split_once('=') {
295 if key.trim() == "rustflags" {
296 if clean_toml_value(value) == literal {
297 removed = true;
298 continue;
299 }
300 conflict = true;
301 }
302 }
303 }
304 out.push(line.to_string());
305 }
306 if removed {
307 (out.join("\n") + "\n", CargoEdit::Removed, false)
308 } else if conflict {
309 (text.to_string(), CargoEdit::AlreadySet, true)
310 } else {
311 (text.to_string(), CargoEdit::AlreadySet, false)
312 }
313}
314
315fn strip_toml_comment(value: &str) -> &str {
317 let mut quote: Option<char> = None;
318 let mut escaped = false;
319 for (index, ch) in value.char_indices() {
320 if escaped {
321 escaped = false;
322 continue;
323 }
324 match quote {
325 Some(q) => {
326 if ch == '\\' && q == '"' {
327 escaped = true;
328 } else if ch == q {
329 quote = None;
330 }
331 }
332 None => {
333 if ch == '#' {
334 return &value[..index];
335 }
336 if ch == '"' || ch == '\'' {
337 quote = Some(ch);
338 }
339 }
340 }
341 }
342 value
343}
344
345pub fn read_table_key(text: &str, table: &str, key: &str) -> Option<String> {
350 let header = format!("[{table}]");
351 let dotted = format!("{table}.{key}");
352 let mut in_table = false;
353 let mut at_root = true;
354 for line in text.lines() {
355 let trimmed = line.trim();
356 if trimmed.starts_with('[') && trimmed.ends_with(']') {
357 at_root = false;
358 in_table = trimmed.replace([' ', '\t'], "") == header;
359 continue;
360 }
361 let candidate = trimmed.split_once('=').map(|(k, _)| k.trim());
362 if at_root && candidate == Some(dotted.as_str()) {
364 if let Some((_, value)) = trimmed.split_once('=') {
365 return Some(clean_toml_value(value));
366 }
367 }
368 if !in_table {
369 continue;
370 }
371 if let Some((candidate, value)) = trimmed.split_once('=') {
372 if candidate.trim() != key {
373 continue;
374 }
375 return Some(clean_toml_value(value));
376 }
377 }
378 None
379}
380
381fn clean_toml_value(value: &str) -> String {
382 let value = strip_toml_comment(value).trim();
383 let value = value
384 .strip_prefix('\'')
385 .and_then(|v| v.strip_suffix('\''))
386 .or_else(|| value.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
387 .unwrap_or(value);
388 value.trim().to_string()
389}
390
391pub fn cargo_configured_in(table: &str, key: &str) -> Result<Option<String>> {
393 let path = cargo_config_path()?;
394 if !path.exists() {
395 return Ok(None);
396 }
397 let text = crate::util::read_trusted_config(&path, 1024 * 1024)?;
398 Ok(read_table_key(&text, table, key))
399}
400
401pub fn cargo_configured(key: &str) -> Result<Option<String>> {
403 let path = cargo_config_path()?;
404 if !path.exists() {
405 return Ok(None);
406 }
407 let text = crate::util::read_trusted_config(&path, 1024 * 1024)?;
408 Ok(read_build_key(&text, key))
409}
410
411pub fn read_build_table_key(path: &Path, key: &str) -> Option<String> {
414 let text = crate::util::read_small_text(path, 1024 * 1024).ok()?;
415 read_build_key(&text, key)
416}
417
418pub struct SetupReport {
419 pub root: PathBuf,
421 pub build_dir_edit: CargoEdit,
423 pub wrapper_edit: Option<CargoEdit>,
425 pub fast: Option<FastSetupReport>,
427 pub backup: Option<PathBuf>,
429 pub using_override: bool,
431}
432
433pub struct FastSetupReport {
435 pub target: String,
436 pub lld: PathBuf,
438 pub flags: Vec<String>,
439 pub edit: CargoEdit,
440 pub conflict: bool,
442}
443
444pub fn normalize_root_input(raw: &str) -> Result<PathBuf> {
454 let raw = raw.trim();
455 anyhow::ensure!(!raw.is_empty(), "--cache-root must not be empty");
456 if raw.contains('{') && raw.contains('}') {
457 return Ok(PathBuf::from(raw)); }
459 let home = paths::home()?;
460 if raw == "~" || raw.starts_with("~/") || raw.starts_with("~\\") {
461 let rest = raw[1..].trim_start_matches(['/', '\\']);
462 return Ok(home.join(rest));
463 }
464 let path = PathBuf::from(raw);
465 if path.is_absolute() {
466 return Ok(path);
467 }
468 let cwd = std::env::current_dir().context("resolving a relative --cache-root")?;
469 Ok(cwd.join(path))
470}
471
472pub fn setup(
476 root_arg: Option<&str>,
477 use_default: bool,
478 with_wrapper: Option<&str>,
479 without_wrapper: bool,
480 fast: bool,
481 without_fast: bool,
482) -> Result<SetupReport> {
483 let build_dir_value: String = if let Some(raw) = root_arg {
488 normalize_root_input(raw)?.to_string_lossy().into_owned()
489 } else if use_default {
490 DEFAULT_BUILD_DIR.to_string()
491 } else {
492 paths::cargo_build_dir()?.unwrap_or_else(|| DEFAULT_BUILD_DIR.to_string())
493 };
494
495 validate_config_value(&build_dir_value, "build-dir")?;
496 if let Some(name) = with_wrapper {
497 validate_config_value(name, "rustc-wrapper")?;
498 }
499
500 let root = paths::resolve_template(&build_dir_value)?;
502 crate::util::ensure_windows_user_path(&root, "cache root")?;
503 crate::util::create_private_dir_all(&root)
504 .with_context(|| format!("creating cache dir {}", root.display()))?;
505
506 let cargo_cfg = cargo_config_path()?;
508
509 let fast_linker = if fast || without_fast {
512 Some(crate::fastlink::detect()?)
513 } else {
514 None
515 };
516 let (build_dir_edit, wrapper_edit, fast_report, backup) = if !cargo_cfg.exists() {
517 if let Some(parent) = cargo_cfg.parent() {
518 crate::util::create_private_dir_all(parent)
519 .with_context(|| format!("creating {}", parent.display()))?;
520 }
521 let mut text = format!("[build]\nbuild-dir = '{build_dir_value}'\n");
522 let wrapper_edit = if let Some(name) = with_wrapper {
523 text.push_str(&format!("rustc-wrapper = '{name}'\n"));
524 Some(CargoEdit::Inserted)
525 } else {
526 None
527 };
528 let fast_report = match &fast_linker {
529 Some(lld) if fast => {
530 let literal = lld.rustflags_toml();
531 text.push_str(&format!("\n[target.{}]\nrustflags = {literal}\n", lld.host));
532 Some(FastSetupReport {
533 target: lld.host.clone(),
534 lld: lld.linker.clone(),
535 flags: lld.rustflags(),
536 edit: CargoEdit::Appended,
537 conflict: false,
538 })
539 }
540 _ => None,
541 };
542 crate::util::write_atomic_private(&cargo_cfg, text.as_bytes())?;
543 (CargoEdit::Created, wrapper_edit, fast_report, None)
544 } else {
545 let text = crate::util::read_trusted_config(&cargo_cfg, 1024 * 1024)
546 .with_context(|| format!("reading {}", cargo_cfg.display()))?;
547 let (text, build_dir_edit) = set_build_key(&text, "build-dir", &build_dir_value);
548 let (text, wrapper_edit) = match with_wrapper {
549 Some(name) => {
550 let (text, edit) = set_build_key(&text, "rustc-wrapper", name);
551 (text, Some(edit))
552 }
553 None if without_wrapper => {
554 let (text, edit) = remove_build_key(&text, "rustc-wrapper");
555 (text, Some(edit))
556 }
557 None => (text, None),
558 };
559 let (text, fast_report) = match &fast_linker {
560 Some(lld) => {
561 let literal = lld.rustflags_toml();
562 let (text, edit, conflict) = if fast {
563 set_target_rustflags(&text, &lld.host, &literal)
564 } else {
565 remove_target_rustflags(&text, &lld.host, &literal)
566 };
567 (
568 text,
569 Some(FastSetupReport {
570 target: lld.host.clone(),
571 lld: lld.linker.clone(),
572 flags: lld.rustflags(),
573 edit,
574 conflict,
575 }),
576 )
577 }
578 None => (text, None),
579 };
580 let mut backup = None;
581 let changed = build_dir_edit != CargoEdit::AlreadySet
582 || wrapper_edit
583 .as_ref()
584 .is_some_and(|e| *e != CargoEdit::AlreadySet)
585 || fast_report
586 .as_ref()
587 .is_some_and(|report| report.edit != CargoEdit::AlreadySet);
588 if changed {
589 backup = Some(backup_file(&cargo_cfg)?);
590 crate::util::write_atomic_private(&cargo_cfg, text.as_bytes())?;
591 }
592 (build_dir_edit, wrapper_edit, fast_report, backup)
593 };
594
595 Ok(SetupReport {
596 root,
597 build_dir_edit,
598 wrapper_edit,
599 fast: fast_report,
600 backup,
601 using_override: build_dir_value != DEFAULT_BUILD_DIR,
602 })
603}
604
605fn backup_base(path: &Path) -> PathBuf {
607 let mut name = path.file_name().unwrap_or_default().to_os_string();
608 name.push(".epicenter-bak");
609 path.with_file_name(name)
610}
611
612fn backups(path: &Path) -> Result<Vec<(u64, PathBuf)>> {
618 let base = backup_base(path);
619 let Some(parent) = base.parent() else {
620 return Ok(Vec::new());
621 };
622 let base_name = base
623 .file_name()
624 .unwrap_or_default()
625 .to_string_lossy()
626 .into_owned();
627 let entries = match fs::read_dir(parent) {
628 Ok(entries) => entries,
629 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
630 Err(err) => return Err(err).with_context(|| format!("reading {}", parent.display())),
631 };
632 let mut out = Vec::new();
633 for entry in entries {
634 let entry = entry?;
635 let name = entry.file_name().to_string_lossy().into_owned();
636 let suffix = if name == base_name {
637 0
638 } else if let Some(rest) = name.strip_prefix(&base_name) {
639 match rest.strip_prefix('.').and_then(|n| n.parse::<u64>().ok()) {
640 Some(number) => number,
641 None => continue, }
643 } else {
644 continue;
645 };
646 let Ok(meta) = fs::symlink_metadata(entry.path()) else {
647 continue;
648 };
649 if !meta.is_file() || meta.file_type().is_symlink() || crate::util::is_reparse_point(&meta)
650 {
651 continue;
652 }
653 out.push((suffix, entry.path()));
654 }
655 out.sort_by_key(|(suffix, _)| *suffix);
656 Ok(out)
657}
658
659fn latest_backup(path: &Path) -> Result<Option<PathBuf>> {
661 Ok(backups(path)?.pop().map(|(_, path)| path))
662}
663
664fn backup_file(path: &Path) -> Result<PathBuf> {
666 let base = backup_base(path);
667 let next = backups(path)?
668 .last()
669 .map(|(suffix, _)| suffix + 1)
670 .unwrap_or(0);
671 let mut name = base.file_name().unwrap_or_default().to_os_string();
672 if next > 0 {
673 name.push(format!(".{next}"));
674 }
675 let backup = base.with_file_name(name);
676 let source = path.to_path_buf();
679 crate::util::copy_file_hashed(&source, &backup)
680 .with_context(|| format!("backing up {}", path.display()))?;
681 if let Ok(meta) = fs::metadata(&source) {
682 let _ = fs::set_permissions(&backup, meta.permissions());
683 }
684 Ok(backup)
685}
686
687#[derive(Debug)]
689pub struct RevertReport {
690 pub config: PathBuf,
691 pub backup: PathBuf,
692}
693
694pub fn revert_config() -> Result<RevertReport> {
696 let config = cargo_config_path()?;
697 revert_config_at(&config)
698}
699
700fn revert_config_at(config: &Path) -> Result<RevertReport> {
702 let Some(backup) = latest_backup(config)? else {
703 anyhow::bail!(
704 "no setup backup found next to {} — run `epicenter setup` after a change first",
705 config.display()
706 );
707 };
708 let text = crate::util::read_trusted_config(&backup, 1024 * 1024)
709 .with_context(|| format!("reading backup {}", backup.display()))?;
710 crate::util::write_atomic_private(config, text.as_bytes())
711 .with_context(|| format!("restoring {}", config.display()))?;
712 Ok(RevertReport {
713 config: config.to_path_buf(),
714 backup,
715 })
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721
722 #[test]
723 fn replaced_line_keeps_the_rest_of_the_file() {
724 let src = "[build]\nbuild-dir = '/old'\nrustc-wrapper = \"kache\"\n\n[net]\nx = 1\n";
727 let (out, edit) = set_build_key(src, "build-dir", "/cache");
728 assert_eq!(edit, CargoEdit::Replaced);
729 assert!(
730 out.contains("rustc-wrapper = \"kache\""),
731 "tail deleted:\n{out}"
732 );
733 assert!(out.contains("[net]"), "tail deleted:\n{out}");
734 let dir = out.find("build-dir = '/cache'").unwrap();
735 let net = out.find("[net]").unwrap();
736 assert!(dir < net, "replaced value must stay inside [build]:\n{out}");
737 }
738
739 #[test]
740 fn backups_are_numbered_and_latest_wins() -> Result<()> {
741 let dir = tempfile::tempdir()?;
742 let config = dir.path().join("config.toml");
743 fs::write(&config, "[build]\nbuild-dir = '/one'\n")?;
744
745 let first = backup_file(&config)?;
746 assert_eq!(
747 first.file_name().unwrap(),
748 "config.toml.epicenter-bak",
749 "the first backup has no suffix"
750 );
751 fs::write(&config, "[build]\nbuild-dir = '/two'\n")?;
752 let second = backup_file(&config)?;
753 assert_eq!(
754 second.file_name().unwrap(),
755 "config.toml.epicenter-bak.1",
756 "suffixes are monotonic"
757 );
758 assert_eq!(latest_backup(&config)?, Some(second.clone()));
759 assert_eq!(
760 fs::read_to_string(&first)?,
761 "[build]\nbuild-dir = '/one'\n",
762 "the older backup is untouched"
763 );
764 Ok(())
765 }
766
767 #[test]
768 fn revert_restores_the_newest_backup() -> Result<()> {
769 let dir = tempfile::tempdir()?;
770 let config = dir.path().join("config.toml");
771 fs::write(&config, "one\n")?;
772 let first = backup_file(&config)?;
773 fs::write(&config, "two\n")?;
774 let second = backup_file(&config)?;
775 fs::write(&config, "current\n")?;
776
777 let report = revert_config_at(&config)?;
778 assert_eq!(report.config, config.clone());
779 assert_eq!(report.backup, second);
780 assert_eq!(fs::read_to_string(&config)?, "two\n");
781 assert!(first.is_file() && second.is_file(), "backups are kept");
782
783 let again = revert_config_at(&config)?;
785 assert_eq!(again.backup, second);
786 assert_eq!(fs::read_to_string(&config)?, "two\n");
787 Ok(())
788 }
789
790 #[test]
791 fn revert_without_a_backup_is_a_clear_error() {
792 let dir = tempfile::tempdir().unwrap();
793 let config = dir.path().join("config.toml");
794 let err = revert_config_at(&config).unwrap_err();
795 assert!(err.to_string().contains("no setup backup"), "{err}");
796 }
797
798 #[test]
799 fn inserts_fast_flags_into_an_existing_target_table() {
800 let src = "[build]\nbuild-dir = '/c'\n\n[target.x86_64-unknown-linux-gnu]\nlinker = \"clang\"\n\n[net]\nx = 1\n";
801 let literal = r#"[\"-C\", \"link-arg=-fuse-ld=lld\", \"-C\", \"link-arg=-B/tmp/gcc-ld\"]"#;
802 let (out, edit, conflict) = set_target_rustflags(src, "x86_64-unknown-linux-gnu", literal);
803 assert_eq!(edit, CargoEdit::Inserted);
804 assert!(!conflict);
805 assert!(
806 out.contains("linker = \"clang\""),
807 "existing key lost:\n{out}"
808 );
809 let target = out.find("[target.").unwrap();
810 let flags = out.find("rustflags").unwrap();
811 let net = out.find("[net]").unwrap();
812 assert!(
813 target < flags && flags < net,
814 "flags escaped the table:\n{out}"
815 );
816 assert!(out.contains(literal));
817 }
818
819 #[test]
820 fn fast_flags_are_never_overwritten() {
821 let src = "[target.x86_64-unknown-linux-gnu]\nrustflags = [\"-C\", \"custom\"]\n";
822 let (out, edit, conflict) =
823 set_target_rustflags(src, "x86_64-unknown-linux-gnu", "[\"-C\", \"ours\"]");
824 assert_eq!(edit, CargoEdit::AlreadySet);
825 assert!(conflict);
826 assert_eq!(out, src, "existing flags untouched");
827
828 let same = "[target.x86_64-unknown-linux-gnu]\nrustflags = [\"-C\", \"ours\"]\n";
830 let (out, edit, conflict) =
831 set_target_rustflags(same, "x86_64-unknown-linux-gnu", "[\"-C\", \"ours\"]");
832 assert_eq!(edit, CargoEdit::AlreadySet);
833 assert!(!conflict);
834 assert_eq!(out, same);
835 }
836
837 #[test]
838 fn removes_only_our_fast_flags() {
839 let literal = r#"[\"-C\", \"link-arg=-fuse-ld=lld\"]"#;
840 let src = format!(
841 "[target.x86_64-unknown-linux-gnu]\nlinker = \"clang\"\nrustflags = {literal}\n\n[net]\nx = 1\n"
842 );
843 let (out, edit, conflict) =
844 remove_target_rustflags(&src, "x86_64-unknown-linux-gnu", literal);
845 assert_eq!(edit, CargoEdit::Removed);
846 assert!(!conflict);
847 assert!(!out.contains("rustflags"), "flags not removed:\n{out}");
848 assert!(
849 out.contains("linker = \"clang\""),
850 "other keys kept:\n{out}"
851 );
852
853 let custom = "[target.x86_64-unknown-linux-gnu]\nrustflags = [\"-C\", \"custom\"]\n";
854 let (out, edit, conflict) =
855 remove_target_rustflags(custom, "x86_64-unknown-linux-gnu", "[\"-C\", \"ours\"]");
856 assert_eq!(edit, CargoEdit::AlreadySet);
857 assert!(conflict);
858 assert_eq!(out, custom, "custom flags untouched");
859 }
860
861 #[test]
862 fn fast_flags_create_the_target_table_when_missing() {
863 let literal = r#"[\"-C\", \"link-arg=-fuse-ld=lld\"]"#;
864 let (out, edit, conflict) = set_target_rustflags(
865 "[build]\nbuild-dir = '/c'\n",
866 "x86_64-unknown-linux-gnu",
867 literal,
868 );
869 assert_eq!(edit, CargoEdit::Appended);
870 assert!(!conflict);
871 assert!(
872 out.contains("[target.x86_64-unknown-linux-gnu]\nrustflags = "),
873 "got:\n{out}"
874 );
875 }
876
877 #[test]
878 fn appends_table_without_merging_lines() {
879 let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
882 assert_eq!(edit, CargoEdit::Appended);
883 assert!(!out.contains("[net]foo"), "lines merged:\n{out}");
884 assert!(out.contains("foo = 1"));
885 let net = out.find("[net]").unwrap();
886 let build = out.rfind("[build]").unwrap();
887 assert!(net < build);
888 }
889
890 #[test]
891 fn appends_build_table_when_missing() {
892 let (out, edit) = set_build_key("[net]\nfoo = 1\n", "build-dir", "/cache");
893 assert_eq!(edit, CargoEdit::Appended);
894 assert!(out.contains("[build]\nbuild-dir = '/cache'"), "got:\n{out}");
895 }
896
897 #[test]
898 fn inserts_into_existing_build_table() {
899 let (out, edit) = set_build_key(
900 "[build]\nrustflags = []\n\n[net]\nx = 1\n",
901 "build-dir",
902 "/cache",
903 );
904 assert_eq!(edit, CargoEdit::Inserted);
905 let build = out.find("[build]").unwrap();
907 let dir = out.find("build-dir").unwrap();
908 let net = out.find("[net]").unwrap();
909 assert!(build < dir && dir < net, "got:\n{out}");
910 }
911
912 #[test]
913 fn replaces_old_value() {
914 let (out, edit) = set_build_key("[build]\nbuild-dir = '/old'\n", "build-dir", "/cache");
915 assert_eq!(edit, CargoEdit::Replaced);
916 assert!(out.contains("build-dir = '/cache'"));
917 assert!(!out.contains("/old"));
918 }
919
920 #[test]
921 fn already_set_is_a_no_op() {
922 let src = "[build]\nbuild-dir = '/cache'\n";
923 let (out, edit) = set_build_key(src, "build-dir", "/cache");
924 assert_eq!(edit, CargoEdit::AlreadySet);
925 assert_eq!(out, src);
926 }
927
928 #[test]
929 fn removes_wrapper_line() {
930 let src = "[build]\nbuild-dir = '/cache'\nrustc-wrapper = 'sccache'\n";
931 let (out, edit) = remove_build_key(src, "rustc-wrapper");
932 assert_eq!(edit, CargoEdit::Removed);
933 assert!(out.contains("build-dir"), "build-dir must survive:\n{out}");
934 assert!(!out.contains("rustc-wrapper"));
935 }
936
937 #[test]
938 fn remove_is_a_no_op_when_absent() {
939 let src = "[build]\nbuild-dir = '/cache'\n";
940 let (out, edit) = remove_build_key(src, "rustc-wrapper");
941 assert_eq!(edit, CargoEdit::AlreadySet);
942 assert_eq!(out, src);
943 }
944
945 #[test]
946 fn reads_key_from_build_table() {
947 assert_eq!(
948 read_build_key("[build]\nbuild-dir = '/cache' # comment\n", "build-dir"),
949 Some("/cache".to_string()),
950 );
951 assert_eq!(read_build_key("[build]\nother = 1\n", "build-dir"), None);
952 assert_eq!(
954 read_build_key("[net]\nbuild-dir = '/x'\n", "build-dir"),
955 None
956 );
957 }
958
959 #[test]
960 fn reads_generic_table_keys() {
961 let text = "[unstable]\nfine-grain-locking = true # concurrent builds\n\n[build]\nbuild-dir = '/x'\n";
962 assert_eq!(
963 read_table_key(text, "unstable", "fine-grain-locking"),
964 Some("true".to_string())
965 );
966 assert_eq!(read_table_key(text, "build", "fine-grain-locking"), None);
967 assert_eq!(
968 read_table_key(text, "build", "build-dir"),
969 Some("/x".to_string())
970 );
971 assert_eq!(
973 read_table_key(
974 "[other]\n# fine-grain-locking = true\n",
975 "unstable",
976 "fine-grain-locking"
977 ),
978 None
979 );
980 }
981
982 #[test]
983 fn config_writes_reject_injection() {
984 assert!(validate_config_value("x'\nrustc-wrapper = '/tmp/evil' #", "build-dir").is_err());
985 assert!(validate_config_value("x\r\ny", "build-dir").is_err());
986 assert!(validate_config_value("x\0y", "build-dir").is_err());
987 assert!(validate_config_value("/tmp/cache", "build-dir").is_ok());
988 }
989
990 #[test]
991 fn removal_is_scoped_to_the_build_table() {
992 let src = "[build]\nbuild-dir = '/x'\n\n[target.x]\nrustc-wrapper = 'keep'\n";
993 let (out, edit) = remove_build_key(src, "rustc-wrapper");
994 assert_eq!(edit, CargoEdit::AlreadySet);
995 assert!(out.contains("rustc-wrapper = 'keep'"));
996 }
997
998 #[test]
999 fn dotted_build_dir_is_detected() {
1000 assert_eq!(
1001 read_build_key("build.build-dir = '/tmp/dotted'\n", "build-dir"),
1002 Some("/tmp/dotted".to_string())
1003 );
1004 }
1005
1006 #[test]
1007 fn comments_inside_quoted_values_are_preserved() {
1008 assert_eq!(
1009 read_build_key("[build]\nbuild-dir = '/tmp/a#b' # tail\n", "build-dir"),
1010 Some("/tmp/a#b".to_string())
1011 );
1012 }
1013
1014 #[test]
1015 fn cache_root_input_is_normalized() {
1016 assert_eq!(
1018 normalize_root_input("{cargo-cache-home}/custom").unwrap(),
1019 PathBuf::from("{cargo-cache-home}/custom"),
1020 );
1021 let absolute = if cfg!(windows) {
1023 r"C:\cache"
1024 } else {
1025 "/tmp/cache"
1026 };
1027 assert_eq!(
1028 normalize_root_input(absolute).unwrap(),
1029 PathBuf::from(absolute),
1030 );
1031 let home = paths::home().unwrap();
1033 assert_eq!(normalize_root_input("~/cache").unwrap(), home.join("cache"));
1034 let cwd = std::env::current_dir().unwrap();
1036 assert_eq!(
1037 normalize_root_input("cache/build").unwrap(),
1038 cwd.join("cache/build"),
1039 );
1040 }
1041}