Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,006 changes: 1,838 additions & 168 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,17 @@ git2 = "0.21.0"
regex = "1.12.4"
clap = { version = "4.6.1", features = ["derive"] }
anyhow = "1.0.103"
keyring = "4"
rpassword = "7.3"
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
serde_json = "1.0"
ureq = { version = "2.10", features = ["json"] }

[dev-dependencies]
tempfile = "3.27.0"
assert_cmd = "2.2.2"
predicates = "3.1.4"

[profile.release]
# opt-level = 3: Maximum optimization level (valid range: 0-3, or "s"/"z" for size)
Expand Down
1 change: 1 addition & 0 deletions gitlance.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
model = "openai/gpt-oss-20b"
85 changes: 85 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: Canonical Ltd.
//
// SPDX-License-Identifier: Apache-2.0

//! Repo-local configuration for `gitlance suggest` (`gitlance.toml`).

use serde::Deserialize;
use std::path::Path;

/// Built-in default model, used when no config file or CLI override is present.
pub const DEFAULT_MODEL: &str = "openai/gpt-oss-20b";

const CONFIG_FILE_NAME: &str = "gitlance.toml";

#[derive(Debug, Deserialize, Default)]
struct RawConfig {
model: Option<String>,
}

/// Resolved gitlance configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub model: String,
}

impl Default for Config {
fn default() -> Self {
Self {
model: DEFAULT_MODEL.to_string(),
}
}
}

impl Config {
/// Loads configuration from `gitlance.toml` in `repo_path`, falling back
/// to built-in defaults for any missing/absent values.
pub fn load(repo_path: &str) -> Self {
let config_path = Path::new(repo_path).join(CONFIG_FILE_NAME);

let raw = std::fs::read_to_string(&config_path)
.ok()
.and_then(|contents| toml::from_str::<RawConfig>(&contents).ok())
.unwrap_or_default();

Self {
model: raw.model.unwrap_or_else(|| DEFAULT_MODEL.to_string()),
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;

#[test]
fn test_load_defaults_when_no_config_file() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let config = Config::load(temp_dir.path().to_str().expect("valid path"));
assert_eq!(config.model, DEFAULT_MODEL);
}

#[test]
fn test_load_reads_model_from_config_file() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
std::fs::write(
temp_dir.path().join("gitlance.toml"),
"model = \"openai/gpt-4o\"\n",
)
.expect("Failed to write config file");

let config = Config::load(temp_dir.path().to_str().expect("valid path"));
assert_eq!(config.model, "openai/gpt-4o");
}

#[test]
fn test_load_defaults_on_invalid_toml() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
std::fs::write(temp_dir.path().join("gitlance.toml"), "not valid toml =")
.expect("Failed to write config file");

let config = Config::load(temp_dir.path().to_str().expect("valid path"));
assert_eq!(config.model, DEFAULT_MODEL);
}
}
62 changes: 62 additions & 0 deletions src/credential_store/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: Canonical Ltd.
//
// SPDX-License-Identifier: Apache-2.0

use std::fmt;

/// Errors that can occur while working with a credential store.
#[derive(Debug)]
pub enum CredentialStoreError {
/// The store could not be initialized (e.g. no keyring backend available).
InitStore(String),
/// Failed to read the token from the store.
TokenRead(String),
/// Failed to write the token to the store.
TokenWrite(String),
/// Failed to delete the token from the store.
TokenDelete(String),
}

impl fmt::Display for CredentialStoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CredentialStoreError::InitStore(msg) => {
write!(f, "Credential store init error: {}", msg)
}
CredentialStoreError::TokenRead(msg) => write!(f, "Token read error: {}", msg),
CredentialStoreError::TokenWrite(msg) => write!(f, "Token write error: {}", msg),
CredentialStoreError::TokenDelete(msg) => write!(f, "Token delete error: {}", msg),
}
}
}

impl std::error::Error for CredentialStoreError {}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_init_store_display() {
let err = CredentialStoreError::InitStore("no backend".to_string());
assert_eq!(err.to_string(), "Credential store init error: no backend");
}

#[test]
fn test_token_read_display() {
let err = CredentialStoreError::TokenRead("missing".to_string());
assert_eq!(err.to_string(), "Token read error: missing");
}

#[test]
fn test_token_write_display() {
let err = CredentialStoreError::TokenWrite("denied".to_string());
assert_eq!(err.to_string(), "Token write error: denied");
}

#[test]
fn test_token_delete_display() {
let err = CredentialStoreError::TokenDelete("not present".to_string());
assert_eq!(err.to_string(), "Token delete error: not present");
}
}
31 changes: 31 additions & 0 deletions src/credential_store/factory.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: Canonical Ltd.
//
// SPDX-License-Identifier: Apache-2.0

use crate::credential_store::{
CredentialStore, CredentialStoreError, FileCredentialStore, KeyringCredentialStore,
};

/// Creates the best available credential store: tries the OS keyring first,
/// falling back to a plain file under `$XDG_RUNTIME_DIR` if the keyring is
/// unavailable (e.g. locked, no D-Bus session), mirroring kfactory's
/// `CredentialStoreFactory.create_store()`.
pub struct CredentialStoreFactory;

impl CredentialStoreFactory {
/// Returns an error only if neither the keyring nor the file fallback
/// could be initialized (e.g. no keyring backend AND `XDG_RUNTIME_DIR` unset).
pub fn create_store() -> Result<Box<dyn CredentialStore>, CredentialStoreError> {
match KeyringCredentialStore::new() {
Ok(store) => Ok(Box::new(store)),
Err(keyring_err) => FileCredentialStore::new()
.map(|store| Box::new(store) as Box<dyn CredentialStore>)
.map_err(|file_err| {
CredentialStoreError::InitStore(format!(
"no usable credential store (keyring: {}; file: {})",
keyring_err, file_err
))
}),
}
}
}
179 changes: 179 additions & 0 deletions src/credential_store/file_store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// SPDX-FileCopyrightText: Canonical Ltd.
//
// SPDX-License-Identifier: Apache-2.0

use crate::credential_store::{CredentialStore, CredentialStoreError};
use std::fs;
use std::io::ErrorKind;
use std::path::PathBuf;

const XDG_RUNTIME_DIR_ENV: &str = "XDG_RUNTIME_DIR";

/// Credential store backed by a plain file, used as a fallback when no OS
/// keyring is available. Mirrors kfactory's `FileCredentialStore`, storing the
/// token under `$XDG_RUNTIME_DIR/gitlance/application_token` (cleared on
/// logout/reboot).
pub struct FileCredentialStore {
token_path: PathBuf,
}

impl FileCredentialStore {
/// Creates a new file-backed store, failing if `XDG_RUNTIME_DIR` is unset.
pub fn new() -> Result<Self, CredentialStoreError> {
let runtime_dir = std::env::var(XDG_RUNTIME_DIR_ENV).map_err(|_| {
CredentialStoreError::InitStore(format!("{} is not set", XDG_RUNTIME_DIR_ENV))
})?;

Ok(Self {
token_path: PathBuf::from(runtime_dir)
.join("gitlance")
.join("application_token"),
})
}

#[cfg(test)]
fn with_path(token_path: PathBuf) -> Self {
Self { token_path }
}
}

impl CredentialStore for FileCredentialStore {
fn get_name(&self) -> &str {
"file"
}

fn read_token(&self) -> Result<Option<String>, CredentialStoreError> {
match fs::read_to_string(&self.token_path) {
Ok(token) => Ok(Some(token)),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
Err(e) => Err(CredentialStoreError::TokenRead(e.to_string())),
}
}

fn write_token(&self, token: &str) -> Result<(), CredentialStoreError> {
if let Some(parent) = self.token_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| CredentialStoreError::TokenWrite(e.to_string()))?;
}

// Create the file with owner-only permissions from the start (rather
// than writing with default permissions and chmod'ing afterwards),
// since this file holds a live API key and the write-then-chmod
// sequence leaves a brief window where it may be readable by
// group/other depending on umask.
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;

let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&self.token_path)
.map_err(|e| CredentialStoreError::TokenWrite(e.to_string()))?;

file.write_all(token.as_bytes())
.map_err(|e| CredentialStoreError::TokenWrite(e.to_string()))?;
}

#[cfg(not(unix))]
{
fs::write(&self.token_path, token)
.map_err(|e| CredentialStoreError::TokenWrite(e.to_string()))?;
}

Ok(())
}

fn delete_token(&self) -> Result<(), CredentialStoreError> {
match fs::remove_file(&self.token_path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(CredentialStoreError::TokenDelete(e.to_string())),
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;

#[test]
fn test_read_token_missing_returns_none() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let store = FileCredentialStore::with_path(temp_dir.path().join("application_token"));

assert_eq!(store.read_token().expect("read_token failed"), None);
}

#[test]
fn test_write_then_read_token_round_trip() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let store = FileCredentialStore::with_path(temp_dir.path().join("application_token"));

store
.write_token("sk-test-token")
.expect("write_token failed");
assert_eq!(
store.read_token().expect("read_token failed"),
Some("sk-test-token".to_string())
);
}

#[test]
fn test_write_token_creates_parent_dir() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let store =
FileCredentialStore::with_path(temp_dir.path().join("nested/dir/application_token"));

store
.write_token("sk-test-token")
.expect("write_token failed");
assert_eq!(
store.read_token().expect("read_token failed"),
Some("sk-test-token".to_string())
);
}

#[test]
fn test_delete_token_removes_file() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let store = FileCredentialStore::with_path(temp_dir.path().join("application_token"));

store
.write_token("sk-test-token")
.expect("write_token failed");
store.delete_token().expect("delete_token failed");
assert_eq!(store.read_token().expect("read_token failed"), None);
}

#[test]
fn test_delete_token_missing_is_ok() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let store = FileCredentialStore::with_path(temp_dir.path().join("application_token"));

assert!(store.delete_token().is_ok());
}

#[test]
#[cfg(unix)]
fn test_write_token_sets_restrictive_permissions() {
use std::os::unix::fs::PermissionsExt;

let temp_dir = TempDir::new().expect("Failed to create temp dir");
let store = FileCredentialStore::with_path(temp_dir.path().join("application_token"));

store
.write_token("sk-test-token")
.expect("write_token failed");
let mode = fs::metadata(&store.token_path)
.expect("metadata failed")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
}
Loading