vtcode-config 0.98.7

Config loader components shared across VT Code and downstream adopters
Documentation
use anyhow::{Context, Result, anyhow};
use std::fs::{self, File};
use std::io::{BufWriter, Write};
use std::path::Path;

use tempfile::Builder;

pub fn read_workspace_env_value(workspace: &Path, env_key: &str) -> Result<Option<String>> {
    let env_path = workspace.join(".env");
    let iter = match dotenvy::from_path_iter(&env_path) {
        Ok(iter) => iter,
        Err(dotenvy::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
            return Ok(None);
        }
        Err(err) => {
            return Err(anyhow!(err).context(format!("Failed to read {}", env_path.display())));
        }
    };

    for item in iter {
        let (key, value) = item
            .map_err(|err: dotenvy::Error| anyhow!(err))
            .with_context(|| format!("Failed to parse {}", env_path.display()))?;
        if key == env_key {
            if value.trim().is_empty() {
                return Ok(None);
            }
            return Ok(Some(value));
        }
    }

    Ok(None)
}

pub fn write_workspace_env_value(workspace: &Path, key: &str, value: &str) -> Result<()> {
    let env_path = workspace.join(".env");
    let mut lines = read_existing_lines(&env_path)?;
    upsert_env_line(&mut lines, key, value);

    let parent = env_path.parent().unwrap_or(workspace);
    fs::create_dir_all(parent)
        .with_context(|| format!("Failed to create directory {}", parent.display()))?;

    let temp = Builder::new()
        .prefix(".env.")
        .suffix(".tmp")
        .tempfile_in(parent)
        .with_context(|| format!("Failed to create temporary file in {}", parent.display()))?;

    set_private_permissions(temp.as_file(), temp.path())?;

    {
        let mut writer = BufWriter::new(temp.as_file());
        for line in &lines {
            writeln!(writer, "{line}")
                .with_context(|| format!("Failed to write .env entry for {key}"))?;
        }
        writer
            .flush()
            .with_context(|| format!("Failed to flush temporary .env for {}", key))?;
    }

    temp.as_file()
        .sync_all()
        .with_context(|| format!("Failed to sync temporary .env for {}", key))?;

    let _persisted = temp
        .persist(&env_path)
        .with_context(|| format!("Failed to persist {}", env_path.display()))?;

    set_private_path_permissions(&env_path)?;
    Ok(())
}

fn read_existing_lines(env_path: &Path) -> Result<Vec<String>> {
    if !env_path.exists() {
        return Ok(Vec::new());
    }

    let contents = fs::read_to_string(env_path)
        .with_context(|| format!("Failed to read {}", env_path.display()))?;
    Ok(contents.lines().map(|line| line.to_string()).collect())
}

fn upsert_env_line(lines: &mut Vec<String>, key: &str, value: &str) {
    let mut replaced = false;
    for line in lines.iter_mut() {
        if let Some((existing_key, _)) = line.split_once('=')
            && existing_key.trim() == key
        {
            *line = format!("{key}={value}");
            replaced = true;
        }
    }

    if !replaced {
        lines.push(format!("{key}={value}"));
    }
}

#[cfg(unix)]
fn set_private_permissions(file: &File, path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;

    file.set_permissions(fs::Permissions::from_mode(0o600))
        .with_context(|| format!("Failed to set permissions on {}", path.display()))
}

#[cfg(not(unix))]
fn set_private_permissions(_file: &File, _path: &Path) -> Result<()> {
    Ok(())
}

#[cfg(unix)]
fn set_private_path_permissions(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;

    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
        .with_context(|| format!("Failed to set permissions on {}", path.display()))
}

#[cfg(not(unix))]
fn set_private_path_permissions(_path: &Path) -> Result<()> {
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{read_workspace_env_value, write_workspace_env_value};
    use anyhow::Result;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn read_returns_value_when_present() -> Result<()> {
        let dir = tempdir()?;
        fs::write(dir.path().join(".env"), "OPENAI_API_KEY=sk-test\n")?;

        let value = read_workspace_env_value(dir.path(), "OPENAI_API_KEY")?;

        assert_eq!(value, Some("sk-test".to_string()));
        Ok(())
    }

    #[test]
    fn read_returns_none_when_missing() -> Result<()> {
        let dir = tempdir()?;

        let value = read_workspace_env_value(dir.path(), "OPENAI_API_KEY")?;

        assert_eq!(value, None);
        Ok(())
    }

    #[test]
    fn write_adds_new_key() -> Result<()> {
        let dir = tempdir()?;

        write_workspace_env_value(dir.path(), "OPENAI_API_KEY", "sk-test")?;

        let contents = fs::read_to_string(dir.path().join(".env"))?;
        assert_eq!(contents, "OPENAI_API_KEY=sk-test\n");
        Ok(())
    }

    #[test]
    fn write_replaces_existing_key() -> Result<()> {
        let dir = tempdir()?;
        fs::write(
            dir.path().join(".env"),
            "OPENAI_API_KEY=old-value\nOTHER_KEY=value\n",
        )?;

        write_workspace_env_value(dir.path(), "OPENAI_API_KEY", "new-value")?;

        let contents = fs::read_to_string(dir.path().join(".env"))?;
        assert_eq!(contents, "OPENAI_API_KEY=new-value\nOTHER_KEY=value\n");
        Ok(())
    }
}