1use std::collections::{BTreeSet, HashMap, HashSet};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, bail};
7use toml_edit::{DocumentMut, Item, TableLike};
8
9use crate::dependencies::normalize;
10use crate::indexes::Index;
11
12#[derive(Clone, Debug)]
13pub struct Project {
14 pub path: PathBuf,
15 pub document: DocumentMut,
16 pub original: Vec<u8>,
17}
18
19pub fn read_project(path: impl AsRef<Path>) -> Result<Project> {
20 let path = path.as_ref();
21 let read = || -> Result<Project> {
22 let original = std::fs::read(path)?;
23 let document = std::str::from_utf8(&original)?.parse::<DocumentMut>()?;
24 Ok(Project {
25 path: path.to_owned(),
26 document,
27 original,
28 })
29 };
30 read().map_err(|error| anyhow::anyhow!("Cannot read {}: {error}", path.display()))
31}
32
33fn uv_config(project: &Project) -> Result<Option<&dyn TableLike>> {
34 let Some(tool) = project.document.get("tool") else {
35 return Ok(None);
36 };
37 let tool = tool
38 .as_table_like()
39 .context("tool and tool.uv must be tables.")?;
40 tool.get("uv")
41 .map(|uv| {
42 uv.as_table_like()
43 .context("tool and tool.uv must be tables.")
44 })
45 .transpose()
46}
47
48fn patterns(config: &dyn TableLike, key: &str, required: bool) -> Result<Vec<String>> {
49 let error = || format!("tool.uv.workspace.{key} must be an array of glob strings.");
50 let Some(value) = config.get(key) else {
51 if required {
52 bail!(error());
53 }
54 return Ok(Vec::new());
55 };
56 value
57 .as_array()
58 .with_context(error)?
59 .iter()
60 .map(|value| value.as_str().map(str::to_owned).with_context(error))
61 .collect()
62}
63
64pub fn load_projects(workspace: bool) -> Result<Vec<Project>> {
65 if !workspace {
66 return Ok(vec![read_project("pyproject.toml")?]);
67 }
68 discover_projects(&std::env::current_dir()?)
69}
70
71fn expand_pattern(directory: &Path, pattern: &str) -> Result<Vec<PathBuf>> {
72 if pattern.is_empty() || Path::new(pattern).is_absolute() {
73 bail!("Workspace glob patterns must be nonempty and relative: {pattern}");
74 }
75 let directory = glob::Pattern::escape(
77 directory
78 .to_str()
79 .context("Workspace directory is not valid UTF-8.")?,
80 );
81 let pattern = format!("{directory}/{pattern}");
82 let options = glob::MatchOptions {
83 require_literal_separator: true,
84 ..Default::default()
85 };
86 glob::glob_with(&pattern, options)?
87 .map(|entry| entry.map_err(Into::into))
88 .collect()
89}
90
91fn discover_projects(current: &Path) -> Result<Vec<Project>> {
93 let mut found = None;
94 for directory in current.ancestors() {
95 let path = directory.join("pyproject.toml");
96 if !path.is_file() {
97 continue;
98 }
99 let root = read_project(path)?;
100 if uv_config(&root)?
101 .and_then(|config| config.get("workspace"))
102 .is_some()
103 {
104 found = Some((directory, root));
105 break;
106 }
107 }
108 let (directory, root) =
109 found.context("No [tool.uv.workspace] found in the current directory or its parents.")?;
110 let config = uv_config(&root)?
111 .and_then(|config| config.get("workspace"))
112 .and_then(Item::as_table_like)
113 .context("tool.uv.workspace must be a table.")?;
114 let members = patterns(config, "members", true)?;
115 let exclusions = patterns(config, "exclude", false)?;
116 let mut excluded = HashSet::new();
117 for pattern in exclusions {
118 for path in expand_pattern(directory, &pattern)? {
119 excluded.insert(std::fs::canonicalize(path)?);
120 }
121 }
122 let root_path = std::fs::canonicalize(&root.path)?;
123 let mut paths = BTreeSet::new();
124 for pattern in members {
125 let matches = expand_pattern(directory, &pattern)?;
126 if matches.is_empty() && !pattern.contains(['*', '?', '[']) {
127 bail!(
128 "Workspace member not found: {}",
129 directory.join(&pattern).display()
130 );
131 }
132 for member in matches {
133 let member = std::fs::canonicalize(member)?;
134 if !excluded.contains(&member) {
135 let path = member.join("pyproject.toml");
136 paths.insert(std::fs::canonicalize(&path).unwrap_or(path));
138 }
139 }
140 }
141 paths.remove(&root_path);
142 let mut projects = vec![root];
143 for path in paths {
144 projects.push(read_project(path)?);
145 }
146 Ok(projects)
147}
148
149fn configured_indexes(config: &dyn TableLike) -> Result<Vec<Index>> {
150 let Some(items) = config.get("index") else {
151 return Ok(Vec::new());
152 };
153 let entries: Vec<&dyn TableLike> = if let Some(items) = items.as_array_of_tables() {
154 items.iter().map(|item| item as &dyn TableLike).collect()
155 } else if let Some(items) = items.as_array() {
156 items
157 .iter()
158 .map(|item| {
159 item.as_inline_table()
160 .map(|item| item as &dyn TableLike)
161 .context("Each tool.uv.index entry needs a URL string.")
162 })
163 .collect::<Result<_>>()?
164 } else {
165 bail!("tool.uv.index must be an array of tables.");
166 };
167 entries
168 .into_iter()
169 .map(|item| {
170 let url = item
171 .get("url")
172 .and_then(Item::as_str)
173 .context("Each tool.uv.index entry needs a URL string.")?;
174 let name = item
175 .get("name")
176 .map(|name| name.as_str().context("Index names must be strings."))
177 .transpose()?;
178 let flag = |key| -> Result<bool> {
179 item.get(key)
180 .map(|value| {
181 value
182 .as_bool()
183 .context("Index explicit and default values must be booleans.")
184 })
185 .transpose()
186 .map(|value| value.unwrap_or(false))
187 };
188 let explicit = flag("explicit")?;
189 let default = flag("default")?;
190 let authenticate = match item.get("authenticate") {
191 None => "auto",
192 Some(value) => match value.as_str() {
193 Some(value @ ("auto" | "always" | "never")) => value,
194 _ => bail!("Index authenticate must be auto, always, or never."),
195 },
196 };
197 Ok(Index {
198 url: url.to_owned(),
199 name: name.map(str::to_owned),
200 explicit,
201 default,
202 authenticate: authenticate.to_owned(),
203 })
204 })
205 .collect()
206}
207
208fn sources<'a>(root: &'a Project, project: &'a Project) -> Result<HashMap<String, &'a Item>> {
209 let mut sources = HashMap::new();
210 for project in [root, project] {
211 let Some(entries) = uv_config(project)?.and_then(|config| config.get("sources")) else {
212 continue;
213 };
214 let entries = entries
215 .as_table_like()
216 .context("tool.uv.sources must be a table.")?;
217 for (name, value) in entries.iter() {
218 sources.insert(normalize(name), value);
219 }
220 }
221 Ok(sources)
222}
223
224fn source_entries(source: &Item) -> Vec<&dyn TableLike> {
225 if let Some(entries) = source.as_array() {
226 entries
227 .iter()
228 .filter_map(|entry| entry.as_inline_table().map(|entry| entry as &dyn TableLike))
229 .collect()
230 } else if let Some(entries) = source.as_array_of_tables() {
231 entries
232 .iter()
233 .map(|entry| entry as &dyn TableLike)
234 .collect()
235 } else {
236 source.as_table_like().into_iter().collect()
237 }
238}
239
240pub fn workspace_dependencies(root: &Project, project: &Project) -> Result<HashSet<String>> {
242 let mut names = HashSet::new();
243 for (name, source) in sources(root, project)? {
244 for entry in source_entries(source) {
245 if let Some(value) = entry.get("workspace") {
246 let enabled = if let Some(value) = value.as_bool() {
247 value
248 } else if let Some(value) = value.as_str() {
249 !value.is_empty()
250 } else {
251 bail!("Source workspace must be a boolean or a string.");
252 };
253 if enabled {
254 names.insert(name.clone());
255 }
256 }
257 }
258 }
259 Ok(names)
260}
261
262pub fn package_indexes(
265 root: &Project,
266 project: &Project,
267 name: &str,
268 urls: &[String],
269 section: &[String],
270) -> Result<Vec<Index>> {
271 if !urls.is_empty() {
272 return Ok(urls
273 .iter()
274 .map(|url| Index {
275 url: url.clone(),
276 name: None,
277 explicit: false,
278 default: true,
279 authenticate: "auto".to_owned(),
280 })
281 .collect());
282 }
283 let mut definitions: Vec<(String, Index)> = Vec::new();
285 for project in [root, project] {
286 if let Some(config) = uv_config(project)? {
287 for index in configured_indexes(config)? {
288 let key = index
289 .name
290 .as_deref()
291 .filter(|name| !name.is_empty())
292 .unwrap_or(&index.url)
293 .to_owned();
294 if let Some(slot) = definitions.iter_mut().find(|(name, _)| name == &key) {
295 slot.1 = index;
296 } else {
297 definitions.push((key, index));
298 }
299 }
300 }
301 }
302 let extra =
303 if section.len() == 3 && section[0] == "project" && section[1] == "optional-dependencies" {
304 Some(normalize(§ion[2]))
305 } else {
306 None
307 };
308 let sources = sources(root, project)?;
309 let mut names = HashSet::new();
310 if let Some(source) = sources.get(&normalize(name)) {
311 for entry in source_entries(source) {
312 let Some(index) = entry.get("index") else {
313 continue;
314 };
315 if let Some(value) = entry.get("extra") {
316 let value = value
317 .as_str()
318 .context("Index source extra must be a string.")?;
319 if extra.as_deref() != Some(normalize(value).as_str()) {
320 continue;
321 }
322 }
323 let index = index
324 .as_str()
325 .context("Index source index must be a string.")?;
326 if entry.contains_key("marker") {
327 bail!("Marker-specific index routing for {name} is not supported.");
328 }
329 names.insert(index);
330 }
331 }
332 if names.len() > 1 {
333 bail!("Multiple indexes for one dependency occurrence of {name} are not supported.");
334 }
335 if let Some(index_name) = names.into_iter().next() {
336 let Some((_, mut index)) = definitions.into_iter().find(|(key, _)| key == index_name)
337 else {
338 bail!("Undefined named index for {name}.");
339 };
340 index.explicit = false;
341 index.default = true;
342 return Ok(vec![index]);
343 }
344 let mut indexes: Vec<_> = definitions
345 .into_iter()
346 .map(|(_, index)| index)
347 .filter(|index| !index.explicit || index.default)
348 .collect();
349 indexes.sort_by_key(|index| index.default);
350 Ok(indexes)
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use tempfile::TempDir;
357
358 fn project(directory: &Path, name: &str, text: &str) -> Project {
359 let path = directory.join(name);
360 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
361 std::fs::write(&path, text).unwrap();
362 read_project(path).unwrap()
363 }
364
365 fn section(parts: &[&str]) -> Vec<String> {
366 parts.iter().map(|part| (*part).to_owned()).collect()
367 }
368
369 #[test]
370 fn reads_bytes_and_preserves_format() {
371 let temp = TempDir::new().unwrap();
372 let text = "# Comment\n[project]\nname = 'demo'\n";
373 let loaded = project(temp.path(), "pyproject.toml", text);
374 assert_eq!(loaded.original, text.as_bytes());
375 assert_eq!(loaded.document.to_string(), text);
376 assert_eq!(loaded.clone().path, loaded.path);
377 for (name, bytes) in [
378 ("invalid.toml", b"[project".as_slice()),
379 ("utf8.toml", &[0xff]),
380 ] {
381 let path = temp.path().join(name);
382 std::fs::write(&path, bytes).unwrap();
383 assert!(
384 read_project(path)
385 .unwrap_err()
386 .to_string()
387 .contains("Cannot read")
388 );
389 }
390 assert!(read_project(temp.path().join("missing.toml")).is_err());
391 }
392
393 #[test]
394 fn discovers_root_sorted_unique_members_and_excludes() {
395 let temp = TempDir::new().unwrap();
396 let root = project(
397 temp.path(),
398 "pyproject.toml",
399 "[tool.uv.workspace]\nmembers = ['packages/*', 'packages/a', '.']\nexclude = ['packages/ex*']\n",
400 );
401 let b = project(
402 temp.path(),
403 "packages/b/pyproject.toml",
404 "[project]\nname = 'b'\n",
405 );
406 let a = project(
407 temp.path(),
408 "packages/a/pyproject.toml",
409 "[project]\nname = 'a'\n",
410 );
411 project(temp.path(), "packages/excluded/pyproject.toml", "");
412 std::fs::write(
413 temp.path().join("packages/excluded/pyproject.toml"),
414 "invalid TOML",
415 )
416 .unwrap();
417 project(temp.path(), "unrelated/pyproject.toml", "");
418 let start = a.path.parent().unwrap().join("src");
419 std::fs::create_dir(&start).unwrap();
420 let found = discover_projects(&start).unwrap();
421 let paths: Vec<_> = found.into_iter().map(|project| project.path).collect();
422 assert_eq!(
423 paths,
424 [
425 root.path,
426 std::fs::canonicalize(a.path).unwrap(),
427 std::fs::canonicalize(b.path).unwrap(),
428 ]
429 );
430 }
431
432 #[test]
433 fn nearest_workspace_and_recursive_globs() {
434 let temp = TempDir::new().unwrap();
435 project(
436 temp.path(),
437 "pyproject.toml",
438 "[tool.uv.workspace]\nmembers = ['absent']\n",
439 );
440 let root = project(
441 temp.path(),
442 "inner/pyproject.toml",
443 "[tool.uv.workspace]\nmembers = ['packages/**/app', 'empty*']\n",
444 );
445 let a = project(temp.path(), "inner/packages/app/pyproject.toml", "");
446 let b = project(temp.path(), "inner/packages/nested/app/pyproject.toml", "");
447 let found = discover_projects(b.path.parent().unwrap()).unwrap();
448 assert_eq!(
449 found.into_iter().map(|p| p.path).collect::<Vec<_>>(),
450 [
451 root.path,
452 std::fs::canonicalize(a.path).unwrap(),
453 std::fs::canonicalize(b.path).unwrap(),
454 ]
455 );
456 }
457
458 #[test]
459 fn invalid_workspace_configuration_and_members_fail_without_writes() {
460 let temp = TempDir::new().unwrap();
461 for text in [
462 "[tool.uv.workspace]\n",
463 "[tool.uv.workspace]\nmembers = 'packages/*'\n",
464 "[tool.uv.workspace]\nmembers = [1]\n",
465 "[tool.uv.workspace]\nmembers = []\nexclude = 'packages/*'\n",
466 "[tool.uv]\nworkspace = true\n",
467 "[tool.uv.workspace]\nmembers = ['missing']\n",
468 ] {
469 let root = project(temp.path(), "pyproject.toml", text);
470 assert!(discover_projects(temp.path()).is_err(), "{text}");
471 assert_eq!(std::fs::read(root.path).unwrap(), text.as_bytes());
472 }
473 project(
474 temp.path(),
475 "pyproject.toml",
476 "[tool.uv.workspace]\nmembers = ['member']\n",
477 );
478 std::fs::create_dir(temp.path().join("member")).unwrap();
479 assert!(
480 discover_projects(temp.path())
481 .unwrap_err()
482 .to_string()
483 .contains("Cannot read")
484 );
485 std::fs::write(temp.path().join("member/pyproject.toml"), "[invalid").unwrap();
486 assert!(discover_projects(temp.path()).is_err());
487 }
488
489 #[test]
490 fn absent_workspace_and_invalid_ancestor_are_errors() {
491 let temp = TempDir::new().unwrap();
492 project(
493 temp.path(),
494 "pyproject.toml",
495 "[project]\nname = 'standalone'\n",
496 );
497 assert!(
498 discover_projects(temp.path())
499 .unwrap_err()
500 .to_string()
501 .contains("No [tool.uv.workspace]")
502 );
503 std::fs::write(temp.path().join("pyproject.toml"), "[invalid").unwrap();
504 let member = project(temp.path(), "member/pyproject.toml", "");
505 assert!(discover_projects(member.path.parent().unwrap()).is_err());
506 let member = project(
507 temp.path(),
508 "member/pyproject.toml",
509 "[tool.uv.workspace]\nmembers = []\n",
510 );
511 let found = discover_projects(member.path.parent().unwrap()).unwrap();
512 assert_eq!(found.len(), 1);
513 assert_eq!(found[0].path, member.path);
514 }
515
516 #[test]
517 fn glob_directory_characters_and_hidden_members() {
518 let temp = TempDir::new().unwrap();
519 let root = project(
520 temp.path(),
521 "workspace[1]/pyproject.toml",
522 "[tool.uv.workspace]\nmembers = ['packages/*']\n",
523 );
524 let member = project(
525 temp.path(),
526 "workspace[1]/packages/.hidden/pyproject.toml",
527 "",
528 );
529 let found = discover_projects(root.path.parent().unwrap()).unwrap();
530 assert_eq!(found.len(), 2);
531 assert_eq!(found[1].path, std::fs::canonicalize(member.path).unwrap());
532 }
533
534 #[test]
535 fn workspace_sources_merge_by_normalized_name() {
536 let temp = TempDir::new().unwrap();
537 let root = project(
538 temp.path(),
539 "root.toml",
540 "[tool.uv.sources]\nBird_Feeder = {workspace = true}\nother = {workspace = true}\n",
541 );
542 let member = project(
543 temp.path(),
544 "member.toml",
545 "[tool.uv.sources]\nbird-feeder = {}\npytest = [{workspace = true, marker = 'ignored'}]\ntyper = {workspace = '../../external'}\nno = {workspace = false}\nempty = {workspace = ''}\n",
546 );
547 assert_eq!(
548 workspace_dependencies(&root, &member).unwrap(),
549 HashSet::from(["other".to_owned(), "pytest".to_owned(), "typer".to_owned()])
550 );
551 }
552
553 #[test]
554 fn ordered_indexes_member_overrides_defaults_and_explicit() {
555 let temp = TempDir::new().unwrap();
556 let root = project(
557 temp.path(),
558 "root.toml",
559 "[[tool.uv.index]]\nname = 'fallback'\nurl = 'https://fallback/simple'\ndefault = true\n[[tool.uv.index]]\nname = 'first'\nurl = 'https://root/simple'\n[[tool.uv.index]]\nname = 'hidden'\nurl = 'https://hidden/simple'\nexplicit = true\n[[tool.uv.index]]\nname = 'blocked'\nurl = 'https://blocked/simple'\nexplicit = true\ndefault = true\n[[tool.uv.index]]\nurl = 'https://unnamed/simple'\n[tool.uv.sources]\ndemo = {index = 'hidden'}\n",
560 );
561 let member = project(
562 temp.path(),
563 "member.toml",
564 "[[tool.uv.index]]\nname = 'first'\nurl = 'https://member/simple'\n[[tool.uv.index]]\nname = 'last'\nurl = 'https://last/simple'\n[[tool.uv.index]]\nname = 'hidden'\nurl = 'https://member-hidden/simple'\nexplicit = true\nauthenticate = 'never'\n",
565 );
566 let indexes = package_indexes(&root, &member, "other", &[], &[]).unwrap();
567 assert_eq!(
568 indexes.iter().map(|i| i.url.as_str()).collect::<Vec<_>>(),
569 [
570 "https://member/simple",
571 "https://unnamed/simple",
572 "https://last/simple",
573 "https://fallback/simple",
574 "https://blocked/simple"
575 ]
576 );
577 assert!(indexes.last().unwrap().explicit);
578 let named = package_indexes(&root, &member, "demo", &[], &[]).unwrap();
579 assert_eq!(named.len(), 1);
580 assert_eq!(named[0].name.as_deref(), Some("hidden"));
581 assert_eq!(named[0].url, "https://member-hidden/simple");
582 assert_eq!(named[0].authenticate, "never");
583 assert!(!named[0].explicit);
584 assert!(named[0].default);
585 }
586
587 const EXTRA_INDEXES: &str = "[[tool.uv.index]]\nname = 'cpu'\nurl = 'https://cpu/simple'\nexplicit = true\nauthenticate = 'always'\n[[tool.uv.index]]\nname = 'gpu'\nurl = 'https://gpu/simple'\nexplicit = true\n[tool.uv.sources]\n";
588
589 #[test]
590 fn extra_routing_is_per_occurrence_and_normalized() {
591 let temp = TempDir::new().unwrap();
592 let root = project(
593 temp.path(),
594 "root.toml",
595 &format!(
596 "{EXTRA_INDEXES}Demo_Pkg = [{{index = 'cpu', extra = 'CPU_cache'}}, {{index = 'gpu', extra = 'gpu'}}]\n"
597 ),
598 );
599 let member = project(temp.path(), "member.toml", "");
600 for parts in [
601 &["project", "dependencies"][..],
602 &["dependency-groups", "cpu-cache"],
603 &[],
604 ] {
605 assert!(
606 package_indexes(&root, &member, "demo-pkg", &[], §ion(parts))
607 .unwrap()
608 .is_empty()
609 );
610 }
611 for (extra, name) in [("cpu-cache", "cpu"), ("gpu", "gpu")] {
612 let indexes = package_indexes(
613 &root,
614 &member,
615 "demo-pkg",
616 &[],
617 §ion(&["project", "optional-dependencies", extra]),
618 )
619 .unwrap();
620 assert_eq!(indexes.len(), 1);
621 assert_eq!(indexes[0].name.as_deref(), Some(name));
622 assert!(indexes[0].default);
623 assert!(!indexes[0].explicit);
624 if name == "cpu" {
625 assert_eq!(indexes[0].authenticate, "always");
626 }
627 }
628 let override_member = project(
629 temp.path(),
630 "member.toml",
631 "[tool.uv.sources]\ndemo-pkg = {index = 'gpu'}\n",
632 );
633 let indexes = package_indexes(&root, &override_member, "demo-pkg", &[], &[]).unwrap();
634 assert_eq!(indexes[0].name.as_deref(), Some("gpu"));
635 }
636
637 #[test]
638 fn ambiguous_marked_undefined_and_invalid_sources_fail() {
639 let temp = TempDir::new().unwrap();
640 for source in [
641 "demo = [{index = 'cpu', extra = 'cpu'}, {index = 'gpu', extra = 'cpu'}]",
642 "demo = {index = 'cpu', extra = 'cpu', marker = 'python_version < \\\"3.12\\\"'}",
643 "demo = {index = 'cpu', extra = ['cpu']}",
644 "demo = {index = ['cpu']}",
645 "demo = {index = 'undefined'}",
646 ] {
647 let root = project(
648 temp.path(),
649 "root.toml",
650 &format!("{EXTRA_INDEXES}{source}\n"),
651 );
652 assert!(
653 package_indexes(
654 &root,
655 &root,
656 "demo",
657 &[],
658 §ion(&["project", "optional-dependencies", "cpu"])
659 )
660 .is_err(),
661 "{source}"
662 );
663 }
664 let root = project(
665 temp.path(),
666 "root.toml",
667 &format!(
668 "{EXTRA_INDEXES}demo = {{index = 'cpu', extra = 'cpu', marker = 'ignored'}}\n"
669 ),
670 );
671 assert!(
672 package_indexes(&root, &root, "demo", &[], &[])
673 .unwrap()
674 .is_empty()
675 );
676 }
677
678 #[test]
679 fn strict_config_types_and_cli_override() {
680 let temp = TempDir::new().unwrap();
681 for text in [
682 "tool = 1",
683 "[tool]\nuv = false",
684 "[tool.uv]\nsources = []",
685 "[tool.uv]\nindex = 'invalid'",
686 "[tool.uv]\nindex = [1]",
687 "[[tool.uv.index]]\nname = 'internal'",
688 "[[tool.uv.index]]\nurl = 3",
689 "[[tool.uv.index]]\nname = 3\nurl = 'https://example/simple'",
690 "[[tool.uv.index]]\nurl = 'https://example/simple'\nexplicit = 'true'",
691 "[[tool.uv.index]]\nurl = 'https://example/simple'\ndefault = 1",
692 "[[tool.uv.index]]\nurl = 'https://example/simple'\nauthenticate = false",
693 "[[tool.uv.index]]\nurl = 'https://example/simple'\nauthenticate = ['never']",
694 "[[tool.uv.index]]\nurl = 'https://example/simple'\nauthenticate = 'sometimes'",
695 ] {
696 let root = project(temp.path(), "root.toml", text);
697 assert!(
698 package_indexes(&root, &root, "demo", &[], &[]).is_err(),
699 "{text}"
700 );
701 let indexes = package_indexes(
702 &root,
703 &root,
704 "demo",
705 &["https://cli/simple".to_owned()],
706 &[],
707 )
708 .unwrap();
709 assert_eq!(indexes.len(), 1);
710 assert_eq!(indexes[0].url, "https://cli/simple");
711 assert!(indexes[0].default);
712 }
713 }
714
715 #[test]
716 fn inline_tables_and_duplicate_routes_work() {
717 let temp = TempDir::new().unwrap();
718 let root = project(
719 temp.path(),
720 "root.toml",
721 "tool = {uv = {index = [{name = 'cpu', url = 'https://cpu/simple'}], sources = {demo = [{index = 'cpu'}, {index = 'cpu'}]}}}",
722 );
723 let indexes = package_indexes(&root, &root, "demo", &[], &[]).unwrap();
724 assert_eq!(indexes.len(), 1);
725 assert_eq!(indexes[0].name.as_deref(), Some("cpu"));
726 }
727}