Repository: astral-sh/ruff
File: crates/ruff_workspace/src/pyproject.rs (Rust)

Code before:
    requires_python: Option<VersionSpecifiers>,
}

#[derive(Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Pyproject {
    tool: Option<Tools>,
    project: Option<Project>,
}

impl Pyproject {
    pub const fn new(options: Options) -> Self {
        Self {
            tool: Some(Tools {
                ruff: Some(options),
            }),
            project: None,
        }
    }
}

/// Parse a `ruff.toml` file.
fn parse_ruff_toml<P: AsRef<Path>>(path: P) -> Result<Options> {
    let path = path.as_ref();
    let contents = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read {}", path.display()))?;
    check_required_version(&contents, path, &[])?;
    toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))
}

/// Parse a `pyproject.toml` file.
fn parse_pyproject_toml<P: AsRef<Path>>(path: P) -> Result<Pyproject> {
    let path = path.as_ref();
    let contents = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read {}", path.display()))?;
    check_required_version(&contents, path, &["tool", "ruff"])?;
    toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))
}

/// Return `true` if a `pyproject.toml` contains a `[tool.ruff]` section.
pub fn ruff_enabled<P: AsRef<Path>>(path: P) -> Result<bool> {
    let pyproject = parse_pyproject_toml(path)?;
    Ok(pyproject.tool.and_then(|tool| tool.ruff).is_some())
}

/// Return the path to the `pyproject.toml` or `ruff.toml` file in a given
/// directory.
pub fn settings_toml<P: AsRef<Path>>(path: P) -> Result<Option<PathBuf>> {
    let path = path.as_ref();
    // Check for `.ruff.toml`.
    let ruff_toml = path.join(".ruff.toml");
    if ruff_toml.is_file() {

Review comment (bug):
I think we can avoid the double parsing by doing something like this:

```rust
    let value: toml::Value =
        toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))?;

    check_required_version(&value, path, &["tool", "ruff"])?;

    value
        .try_into()
        .with_context(|| format!("Failed to parse {}", path.display()))
```

Code after:
    #[serde(alias = "requires-python", alias = "requires_python")]
    requires_python: Option<VersionSpecifiers>,
}

#[derive(Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Pyproject {
    tool: Option<Tools>,
    project: Option<Project>,
}

impl Pyproject {
    pub const fn new(options: Options) -> Self {
        Self {
            tool: Some(Tools {
                ruff: Some(options),
            }),
            project: None,
        }
    }
}

fn parse_toml<P: AsRef<Path>, T: DeserializeOwned>(path: P, table_path: &[&str]) -> Result<T> {
    let path = path.as_ref();
    let contents = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read {}", path.display()))?;

    // Parse the TOML document once into a spanned representation so we can:
    // - Inspect `required-version` without triggering strict deserialization errors.
    // - Deserialize with precise spans (line/column and excerpt) on errors.
    let root = toml::de::DeTable::parse(&contents)
        .with_context(|| format!("Failed to parse {}", path.display()))?;

    check_required_version(root.get_ref(), table_path)?;

    let deserializer = toml::de::Deserializer::from(root);
    T::deserialize(deserializer)
        .map_err(|mut err| {
            // `Deserializer::from` doesn't have access to the original input, but we do.
            // Attach it so TOML errors include line/column and a source excerpt.
            err.set_input(Some(&contents));
            err
        })
        .with_context(|| format!("Failed to parse {}", path.display()))
}

/// Parse a `ruff.toml` file.
fn parse_ruff_toml<P: AsRef<Path>>(path: P) -> Result<Options> {
    parse_toml(path, &[])
}

/// Parse a `pyproject.toml` file.