Skip to main content

check_updates/
workspace.rs

1//! Discover uv workspaces and resolve dependency index sources without invoking uv.
2
3use 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    // Escape the directory, not the user pattern: workspace paths can contain glob characters.
76    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
91// Accept an explicit start directory so tests do not change the process cwd.
92fn 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                // Keep missing files for read_project to report. Resolve existing file symlinks.
137                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
240/// Respect root sources and member overrides, including external workspace strings.
241pub 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
262/// Member definitions replace root names without changing first-match order.
263/// A selected named source disables public fallback; defaults are searched last.
264pub 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    // A Vec retains Python dict insertion order when a member replaces a root definition.
284    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(&section[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", &[], &section(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                &section(&["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                    &section(&["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}