use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::prelude::*;
use ratatui::widgets::*;
use std::io::stdout;
use std::ops::Range;
use std::time::Duration;
use crate::config::{
CacheFileConfig, Config, EnvOverrides, FileConfig, RemoteFileConfig, default_cache_dir,
normalize_remote_prefix, parse_size, resolve_config_path, shellexpand,
};
#[derive(Debug, Clone, Copy, PartialEq)]
enum FieldKind {
Text,
Bool,
Size,
Usize,
}
#[derive(Debug, Clone)]
struct FormField {
key: &'static str,
label: &'static str,
kind: FieldKind,
value: String,
env_var: &'static str,
env_value: Option<String>,
default_hint: &'static str,
validation_error: Option<String>,
env_locked: bool,
}
impl FormField {
fn display_value(&self) -> &str {
if self.value.is_empty() {
self.default_hint
} else {
&self.value
}
}
fn is_placeholder(&self) -> bool {
self.value.is_empty()
}
}
struct Section {
label: &'static str,
fields: Range<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Mode {
Navigate,
Editing,
ConfirmQuit,
ConfirmSave,
}
struct EditorState {
fields: Vec<FormField>,
sections: Vec<Section>,
cursor: usize,
mode: Mode,
edit_buffer: String,
edit_cursor: usize,
dirty: bool,
status: Option<String>,
file_had_content: bool,
has_saved_once: bool,
scroll_offset: u16,
file_config: FileConfig,
}
fn build_fields(file_config: &FileConfig, env: &EnvOverrides) -> Vec<FormField> {
let cache = file_config.cache.as_ref();
let remote = cache.and_then(|c| c.remote.as_ref());
let remote_type = remote
.and_then(|r| r._type.clone())
.filter(|value| !value.trim().is_empty())
.or_else(|| {
remote.and_then(|r| {
if r.path.is_some() || r.atomic_write_dir.is_some() {
Some("filesystem".to_string())
} else if r.bucket.is_some()
|| r.endpoint.is_some()
|| r.region.is_some()
|| r.prefix.is_some()
|| r.profile.is_some()
{
Some("s3".to_string())
} else {
None
}
})
})
.unwrap_or_default();
let filesystem_remote =
remote_type.eq_ignore_ascii_case("filesystem") || remote_type.eq_ignore_ascii_case("fs");
let default_dir = default_cache_dir().to_string_lossy().to_string();
let default_dir_hint: &'static str = Box::leak(format!("(default: {default_dir})").into());
let env_val = |var: &str| -> Option<String> { std::env::var(var).ok() };
vec![
FormField {
key: "cache_dir",
label: "Cache directory",
kind: FieldKind::Text,
value: cache
.and_then(|c| c.local_store.clone())
.unwrap_or_default(),
env_var: "KACHE_CACHE_DIR",
env_value: env_val("KACHE_CACHE_DIR"),
default_hint: default_dir_hint,
validation_error: None,
env_locked: env.cache_dir,
},
FormField {
key: "max_size",
label: "Max store size",
kind: FieldKind::Size,
value: cache
.and_then(|c| c.local_max_size.clone())
.unwrap_or_default(),
env_var: "KACHE_MAX_SIZE",
env_value: env_val("KACHE_MAX_SIZE"),
default_hint: "(default: 5% of the cache disk, 5GiB–100GiB)",
validation_error: None,
env_locked: env.max_size,
},
FormField {
key: "disabled",
label: "Disabled",
kind: FieldKind::Bool,
value: String::new(),
env_var: "KACHE_DISABLED",
env_value: env_val("KACHE_DISABLED"),
default_hint: "false",
validation_error: None,
env_locked: true, },
FormField {
key: "cache_executables",
label: "Cache executables",
kind: FieldKind::Bool,
value: cache
.and_then(|c| c.cache_executables)
.map(|b| b.to_string())
.unwrap_or_default(),
env_var: "KACHE_CACHE_EXECUTABLES",
env_value: env_val("KACHE_CACHE_EXECUTABLES"),
default_hint: if crate::config::default_cache_executables() {
"true"
} else {
"false"
},
validation_error: None,
env_locked: env.cache_executables,
},
FormField {
key: "clean_incremental",
label: "Clean incremental",
kind: FieldKind::Bool,
value: cache
.and_then(|c| c.clean_incremental)
.map(|b| b.to_string())
.unwrap_or_default(),
env_var: "KACHE_CLEAN_INCREMENTAL",
env_value: env_val("KACHE_CLEAN_INCREMENTAL"),
default_hint: "true",
validation_error: None,
env_locked: env.clean_incremental,
},
FormField {
key: "preserve_incremental",
label: "Preserve incremental",
kind: FieldKind::Bool,
value: cache
.and_then(|c| c.preserve_incremental)
.map(|b| b.to_string())
.unwrap_or_default(),
env_var: "KACHE_PRESERVE_INCREMENTAL",
env_value: env_val("KACHE_PRESERVE_INCREMENTAL"),
default_hint: "false",
validation_error: None,
env_locked: env.preserve_incremental,
},
FormField {
key: "adaptive_incremental",
label: "Adaptive incremental",
kind: FieldKind::Bool,
value: cache
.and_then(|c| c.adaptive_incremental)
.map(|b| b.to_string())
.unwrap_or_default(),
env_var: "KACHE_ADAPTIVE_INCREMENTAL",
env_value: env_val("KACHE_ADAPTIVE_INCREMENTAL"),
default_hint: "true",
validation_error: None,
env_locked: env.adaptive_incremental,
},
FormField {
key: "fallback",
label: "Fallback wrapper",
kind: FieldKind::Text,
value: cache.and_then(|c| c.fallback.clone()).unwrap_or_default(),
env_var: "KACHE_FALLBACK",
env_value: env_val("KACHE_FALLBACK"),
default_hint: "(none)",
validation_error: None,
env_locked: env.fallback,
},
FormField {
key: "key_salt",
label: "Cache-key salt",
kind: FieldKind::Text,
value: cache.and_then(|c| c.key_salt.clone()).unwrap_or_default(),
env_var: "KACHE_KEY_SALT",
env_value: env_val("KACHE_KEY_SALT"),
default_hint: "(none)",
validation_error: None,
env_locked: env.key_salt,
},
FormField {
key: "exclude",
label: "Exclude paths",
kind: FieldKind::Text,
value: cache
.and_then(|c| c.exclude.as_ref())
.map(|patterns| patterns.join(", "))
.unwrap_or_default(),
env_var: "",
env_value: None,
default_hint: "(none)",
validation_error: None,
env_locked: false,
},
FormField {
key: "remote_type",
label: "Backend type",
kind: FieldKind::Text,
value: remote_type,
env_var: "",
env_value: None,
default_hint: "(s3 or filesystem)",
validation_error: None,
env_locked: false,
},
FormField {
key: "s3_bucket",
label: "Bucket",
kind: FieldKind::Text,
value: remote.and_then(|r| r.bucket.clone()).unwrap_or_default(),
env_var: if env.s3_bucket { "KACHE_S3_BUCKET" } else { "" },
env_value: if env.s3_bucket {
env_val("KACHE_S3_BUCKET")
} else {
None
},
default_hint: "(none)",
validation_error: None,
env_locked: !filesystem_remote && env.s3_bucket,
},
FormField {
key: "s3_endpoint",
label: "Endpoint",
kind: FieldKind::Text,
value: remote.and_then(|r| r.endpoint.clone()).unwrap_or_default(),
env_var: if env.s3_endpoint {
"KACHE_S3_ENDPOINT"
} else {
""
},
env_value: if env.s3_endpoint {
env_val("KACHE_S3_ENDPOINT")
} else {
None
},
default_hint: "(none)",
validation_error: None,
env_locked: !filesystem_remote && env.s3_endpoint,
},
FormField {
key: "s3_region",
label: "Region",
kind: FieldKind::Text,
value: remote.and_then(|r| r.region.clone()).unwrap_or_default(),
env_var: if env.s3_region { "KACHE_S3_REGION" } else { "" },
env_value: if env.s3_region {
env_val("KACHE_S3_REGION")
} else {
None
},
default_hint: "(default: us-east-1)",
validation_error: None,
env_locked: !filesystem_remote && env.s3_region,
},
FormField {
key: "s3_profile",
label: "AWS Profile",
kind: FieldKind::Text,
value: remote.and_then(|r| r.profile.clone()).unwrap_or_default(),
env_var: if env.s3_profile {
"KACHE_S3_PROFILE"
} else {
""
},
env_value: if env.s3_profile {
env_val("KACHE_S3_PROFILE")
} else {
None
},
default_hint: "(none)",
validation_error: None,
env_locked: !filesystem_remote && env.s3_profile,
},
FormField {
key: "s3_user_agent",
label: "User-Agent",
kind: FieldKind::Text,
value: remote
.and_then(|r| r.user_agent.clone())
.unwrap_or_default(),
env_var: if env.s3_user_agent {
"KACHE_S3_USER_AGENT"
} else {
""
},
env_value: if env.s3_user_agent {
env_val("KACHE_S3_USER_AGENT")
} else {
None
},
default_hint: "(none)",
validation_error: None,
env_locked: !filesystem_remote && env.s3_user_agent,
},
FormField {
key: "fs_path",
label: "Filesystem path",
kind: FieldKind::Text,
value: remote.and_then(|r| r.path.clone()).unwrap_or_default(),
env_var: "",
env_value: None,
default_hint: "(required for filesystem)",
validation_error: None,
env_locked: false,
},
FormField {
key: "fs_atomic_write_dir",
label: "Atomic write dir",
kind: FieldKind::Text,
value: remote
.and_then(|r| r.atomic_write_dir.clone())
.unwrap_or_default(),
env_var: "",
env_value: None,
default_hint: "(optional; same filesystem)",
validation_error: None,
env_locked: false,
},
FormField {
key: "remote_prefix",
label: "Prefix",
kind: FieldKind::Text,
value: remote.and_then(|r| r.prefix.clone()).unwrap_or_default(),
env_var: if env.s3_prefix { "KACHE_S3_PREFIX" } else { "" },
env_value: if env.s3_prefix {
env_val("KACHE_S3_PREFIX")
} else {
None
},
default_hint: "(default: artifacts)",
validation_error: None,
env_locked: !filesystem_remote && env.s3_prefix,
},
FormField {
key: "event_log_max_size",
label: "Event log max",
kind: FieldKind::Size,
value: cache
.and_then(|c| c.event_log_max_size.clone())
.unwrap_or_default(),
env_var: "",
env_value: None,
default_hint: "(default: 10MiB)",
validation_error: None,
env_locked: false,
},
FormField {
key: "event_log_keep_lines",
label: "Event log lines",
kind: FieldKind::Usize,
value: cache
.and_then(|c| c.event_log_keep_lines)
.map(|n| n.to_string())
.unwrap_or_default(),
env_var: "",
env_value: None,
default_hint: "(default: 1000)",
validation_error: None,
env_locked: false,
},
]
}
fn build_sections() -> Vec<Section> {
vec![
Section {
label: "General",
fields: 0..3,
},
Section {
label: "Caching",
fields: 3..10,
},
Section {
label: "Remote",
fields: 10..19,
},
Section {
label: "Advanced",
fields: 19..21,
},
]
}
fn validate_field(field: &FormField) -> Option<String> {
if field.value.is_empty() {
return None;
}
match field.kind {
FieldKind::Size => {
if parse_size(&field.value).is_none() {
Some("Invalid size — try e.g. \"50GiB\", \"500MiB\"".to_string())
} else {
None
}
}
FieldKind::Usize => {
if field.value.parse::<usize>().is_err() {
Some("Must be a positive integer".to_string())
} else {
None
}
}
_ => None,
}
}
fn validate_cross_field(fields: &[FormField]) -> Vec<(usize, String)> {
let mut errors = Vec::new();
let index = |key: &str| fields.iter().position(|f| f.key == key);
let configured_value = |key: &str| {
fields.iter().find(|f| f.key == key).and_then(|f| {
let value = f.value.as_str();
(!value.trim().is_empty()).then_some(value)
})
};
let value = |key: &str| {
fields.iter().find(|f| f.key == key).and_then(|f| {
let value = f.value.trim();
if !value.is_empty() {
Some(value)
} else if f.env_locked {
f.env_value
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
} else {
None
}
})
};
let is_set = |key: &str| value(key).is_some();
let is_configured = |key: &str| configured_value(key).is_some();
let remote_type = value("remote_type").map(str::to_ascii_lowercase);
let has_s3_fields = [
"s3_bucket",
"s3_endpoint",
"s3_region",
"s3_profile",
"s3_user_agent",
]
.iter()
.any(|key| is_set(key));
let has_filesystem_fields = ["fs_path", "fs_atomic_write_dir"]
.iter()
.any(|key| is_set(key));
let has_remote_fields = has_s3_fields || has_filesystem_fields || is_set("remote_prefix");
let has_configured_remote_fields = [
"s3_bucket",
"s3_endpoint",
"s3_region",
"s3_profile",
"s3_user_agent",
"fs_path",
"fs_atomic_write_dir",
"remote_prefix",
]
.iter()
.any(|key| is_configured(key));
if let Some(prefix) = configured_value("remote_prefix")
&& normalize_remote_prefix(prefix).is_err()
&& let Some(idx) = index("remote_prefix")
{
errors.push((
idx,
"Prefix must not contain backslashes or '.'/'..' path segments".to_string(),
));
}
if remote_type.is_none()
&& has_configured_remote_fields
&& let Some(idx) = index("remote_type")
{
errors.push((idx, "Backend type required (s3 or filesystem)".to_string()));
}
let effective_type = remote_type.as_deref().or({
if has_filesystem_fields {
Some("filesystem")
} else if has_remote_fields {
Some("s3")
} else {
None
}
});
match effective_type {
Some("s3") => {
if !is_set("s3_bucket")
&& let Some(idx) = index("s3_bucket")
{
errors.push((idx, "Bucket required for S3 backend".to_string()));
}
for key in ["fs_path", "fs_atomic_write_dir"] {
if is_set(key)
&& let Some(idx) = index(key)
{
errors.push((
idx,
"Filesystem field conflicts with S3 backend".to_string(),
));
}
}
}
Some("filesystem" | "fs") => {
if !is_set("fs_path")
&& let Some(idx) = index("fs_path")
{
errors.push((
idx,
"Filesystem path required for filesystem backend".to_string(),
));
}
for key in ["fs_path", "fs_atomic_write_dir"] {
if let Some(path) = configured_value(key)
&& !shellexpand(path.trim()).is_absolute()
&& let Some(idx) = index(key)
{
errors.push((
idx,
"Filesystem paths must be absolute (or start with ~/)".to_string(),
));
}
}
for key in [
"s3_bucket",
"s3_endpoint",
"s3_region",
"s3_profile",
"s3_user_agent",
] {
if is_configured(key)
&& let Some(idx) = index(key)
{
errors.push((
idx,
"S3 field conflicts with filesystem backend".to_string(),
));
}
}
}
Some(_) => {
if let Some(idx) = index("remote_type") {
errors.push((
idx,
"Backend type must be \"s3\" or \"filesystem\"".to_string(),
));
}
}
None => {}
}
errors
}
fn refresh_remote_env_scope(fields: &mut [FormField]) {
let filesystem = fields
.iter()
.find(|field| field.key == "remote_type")
.is_some_and(|field| {
matches!(
field.value.trim().to_ascii_lowercase().as_str(),
"filesystem" | "fs"
)
});
for field in fields.iter_mut().filter(|field| {
matches!(
field.key,
"s3_bucket"
| "s3_endpoint"
| "s3_region"
| "s3_profile"
| "s3_user_agent"
| "remote_prefix"
)
}) {
field.env_locked = !filesystem && !field.env_var.is_empty();
}
}
fn has_validation_errors(fields: &[FormField]) -> bool {
fields.iter().any(|f| f.validation_error.is_some())
}
fn run_all_validation(fields: &mut [FormField]) {
for field in fields.iter_mut() {
field.validation_error = validate_field(field);
}
for (idx, msg) in validate_cross_field(fields) {
fields[idx].validation_error = Some(msg);
}
}
fn fields_to_file_config(fields: &[FormField], original: &FileConfig) -> FileConfig {
let get = |key: &str| -> Option<String> {
fields.iter().find(|f| f.key == key).and_then(|f| {
if f.value.is_empty() {
None
} else {
Some(f.value.clone())
}
})
};
let get_bool = |key: &str| -> Option<bool> {
fields.iter().find(|f| f.key == key).and_then(|f| {
if f.value.is_empty() {
None
} else {
Some(f.value == "true")
}
})
};
let get_usize = |key: &str| -> Option<usize> {
fields
.iter()
.find(|f| f.key == key)
.and_then(|f| f.value.parse().ok())
};
let get_list = |key: &str| -> Option<Vec<String>> {
let values: Vec<String> = fields
.iter()
.find(|f| f.key == key)
.map(|f| {
f.value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
(!values.is_empty()).then_some(values)
};
let bucket = get("s3_bucket");
let endpoint = get("s3_endpoint");
let region = get("s3_region");
let prefix = get("remote_prefix");
let profile = get("s3_profile");
let user_agent = get("s3_user_agent");
let path = get("fs_path");
let atomic_write_dir = get("fs_atomic_write_dir");
let remote_type = get("remote_type")
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty());
let has_remote = remote_type.is_some()
|| bucket.is_some()
|| endpoint.is_some()
|| region.is_some()
|| prefix.is_some()
|| profile.is_some()
|| user_agent.is_some()
|| path.is_some()
|| atomic_write_dir.is_some();
let remote = if has_remote {
Some(RemoteFileConfig {
_type: remote_type,
bucket,
endpoint,
region,
prefix,
profile,
user_agent,
path,
atomic_write_dir,
})
} else {
None
};
let mut config = original.clone();
let cache = config.cache.get_or_insert_with(CacheFileConfig::default);
cache.local_store = get("cache_dir");
cache.local_max_size = get("max_size");
cache.cache_executables = get_bool("cache_executables");
cache.clean_incremental = get_bool("clean_incremental");
cache.preserve_incremental = get_bool("preserve_incremental");
cache.adaptive_incremental = get_bool("adaptive_incremental");
cache.exclude = get_list("exclude");
cache.event_log_max_size = get("event_log_max_size");
cache.event_log_keep_lines = get_usize("event_log_keep_lines");
cache.fallback = get("fallback");
cache.key_salt = get("key_salt");
cache.remote = remote;
config
}
pub fn run_config_editor() -> Result<()> {
let (file_config, file_existed) = Config::load_raw_file_config();
let env = EnvOverrides::detect();
let mut state = initial_editor_state(&file_config, file_existed, &env);
run_all_validation(&mut state.fields);
let _terminal_mode = crate::tui::TerminalModeGuard::enter()?;
let mut terminal = ratatui::Terminal::new(CrosstermBackend::new(stdout()))?;
run_event_loop(&mut terminal, &mut state)
}
fn initial_editor_state(
file_config: &FileConfig,
file_existed: bool,
env: &EnvOverrides,
) -> EditorState {
let fields = build_fields(file_config, env);
let sections = build_sections();
let initial_cursor = fields.iter().position(|f| !f.env_locked).unwrap_or(0);
EditorState {
fields,
sections,
cursor: initial_cursor,
mode: Mode::Navigate,
edit_buffer: String::new(),
edit_cursor: 0,
dirty: false,
status: None,
file_had_content: file_existed,
has_saved_once: false,
scroll_offset: 0,
file_config: file_config.clone(),
}
}
fn run_event_loop(
terminal: &mut ratatui::Terminal<CrosstermBackend<std::io::Stdout>>,
state: &mut EditorState,
) -> Result<()> {
loop {
terminal.draw(|f| draw(f, state))?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
{
if key.kind != KeyEventKind::Press {
continue;
}
match handle_key(state, key.code, key.modifiers) {
Action::Continue => {}
Action::Quit => break,
}
}
}
Ok(())
}
enum Action {
Continue,
Quit,
}
fn handle_key(state: &mut EditorState, code: KeyCode, modifiers: KeyModifiers) -> Action {
match state.mode {
Mode::Navigate => handle_navigate(state, code, modifiers),
Mode::Editing => handle_editing(state, code),
Mode::ConfirmQuit => handle_confirm_quit(state, code),
Mode::ConfirmSave => handle_confirm_save(state, code),
}
}
fn handle_navigate(state: &mut EditorState, code: KeyCode, modifiers: KeyModifiers) -> Action {
match code {
KeyCode::Up | KeyCode::Char('k') => {
move_cursor(state, -1);
state.status = None;
}
KeyCode::Down | KeyCode::Char('j') => {
move_cursor(state, 1);
state.status = None;
}
KeyCode::Tab => {
jump_section(state, 1);
state.status = None;
}
KeyCode::BackTab => {
jump_section(state, -1);
state.status = None;
}
KeyCode::Enter => {
let field = &state.fields[state.cursor];
if !field.env_locked && field.kind != FieldKind::Bool {
state.edit_buffer = field.value.clone();
state.edit_cursor = state.edit_buffer.len();
state.mode = Mode::Editing;
state.status = None;
}
}
KeyCode::Char(' ') => {
let field = &state.fields[state.cursor];
if !field.env_locked && field.kind == FieldKind::Bool {
let current = field.value == "true"
|| (field.value.is_empty() && field.default_hint == "true");
state.fields[state.cursor].value = (!current).to_string();
state.dirty = true;
state.status = None;
run_all_validation(&mut state.fields);
}
}
KeyCode::Char('s') if !modifiers.contains(KeyModifiers::CONTROL) => {
try_save(state);
}
KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => {
try_save(state);
}
KeyCode::Char('q') | KeyCode::Esc => {
if state.dirty {
state.mode = Mode::ConfirmQuit;
state.status = Some("Unsaved changes. Quit? (y/n)".to_string());
} else {
return Action::Quit;
}
}
_ => {}
}
Action::Continue
}
fn move_cursor(state: &mut EditorState, dir: i32) {
let len = state.fields.len();
if len == 0 {
return;
}
let mut pos = state.cursor as i32;
for _ in 0..len {
pos += dir;
if pos < 0 {
pos = len as i32 - 1;
} else if pos >= len as i32 {
pos = 0;
}
if !state.fields[pos as usize].env_locked {
state.cursor = pos as usize;
return;
}
}
}
fn jump_section(state: &mut EditorState, dir: i32) {
let current_section = state
.sections
.iter()
.position(|s| s.fields.contains(&state.cursor))
.unwrap_or(0) as i32;
let len = state.sections.len() as i32;
let next = ((current_section + dir) % len + len) % len;
let range = &state.sections[next as usize].fields;
for i in range.clone() {
if !state.fields[i].env_locked {
state.cursor = i;
return;
}
}
}
fn try_save(state: &mut EditorState) {
run_all_validation(&mut state.fields);
if has_validation_errors(&state.fields) {
state.status = Some("Fix validation errors before saving".to_string());
return;
}
if state.file_had_content && !state.has_saved_once {
state.mode = Mode::ConfirmSave;
state.status = Some("Existing file will be overwritten. Proceed? (y/n)".to_string());
} else {
do_save(state);
}
}
fn do_save(state: &mut EditorState) {
do_save_to(state, &resolve_config_path());
}
fn do_save_to(state: &mut EditorState, path: &std::path::Path) {
let config = fields_to_file_config(&state.fields, &state.file_config);
match Config::save_file_config_to(&config, path) {
Ok(()) => {
state.dirty = false;
state.has_saved_once = true;
state.status = Some("Saved!".to_string());
}
Err(e) => {
state.status = Some(format!("Save failed: {e}"));
}
}
}
fn handle_editing(state: &mut EditorState, code: KeyCode) -> Action {
match code {
KeyCode::Char(ch) => {
state.edit_buffer.insert(state.edit_cursor, ch);
state.edit_cursor += ch.len_utf8();
}
KeyCode::Backspace if state.edit_cursor > 0 => {
let prev = prev_char_boundary(&state.edit_buffer, state.edit_cursor);
state.edit_buffer.drain(prev..state.edit_cursor);
state.edit_cursor = prev;
}
KeyCode::Delete if state.edit_cursor < state.edit_buffer.len() => {
let next = next_char_boundary(&state.edit_buffer, state.edit_cursor);
state.edit_buffer.drain(state.edit_cursor..next);
}
KeyCode::Left if state.edit_cursor > 0 => {
state.edit_cursor = prev_char_boundary(&state.edit_buffer, state.edit_cursor);
}
KeyCode::Right if state.edit_cursor < state.edit_buffer.len() => {
state.edit_cursor = next_char_boundary(&state.edit_buffer, state.edit_cursor);
}
KeyCode::Home => {
state.edit_cursor = 0;
}
KeyCode::End => {
state.edit_cursor = state.edit_buffer.len();
}
KeyCode::Enter => {
let edited_remote_type = state.fields[state.cursor].key == "remote_type";
state.fields[state.cursor].value = state.edit_buffer.clone();
if edited_remote_type {
refresh_remote_env_scope(&mut state.fields);
}
state.dirty = true;
state.mode = Mode::Navigate;
run_all_validation(&mut state.fields);
}
KeyCode::Esc => {
state.mode = Mode::Navigate;
}
_ => {}
}
Action::Continue
}
fn prev_char_boundary(s: &str, pos: usize) -> usize {
let mut p = pos.saturating_sub(1);
while p > 0 && !s.is_char_boundary(p) {
p -= 1;
}
p
}
fn next_char_boundary(s: &str, pos: usize) -> usize {
let mut p = pos + 1;
while p < s.len() && !s.is_char_boundary(p) {
p += 1;
}
p
}
fn handle_confirm_quit(state: &mut EditorState, code: KeyCode) -> Action {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') => Action::Quit,
_ => {
state.mode = Mode::Navigate;
state.status = None;
Action::Continue
}
}
}
fn handle_confirm_save(state: &mut EditorState, code: KeyCode) -> Action {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
state.mode = Mode::Navigate;
do_save(state);
}
_ => {
state.mode = Mode::Navigate;
state.status = None;
}
}
Action::Continue
}
fn draw(f: &mut ratatui::Frame, state: &mut EditorState) {
let area = f.area();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
draw_title(f, outer[0], state);
f.render_widget(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Color::DarkGray)),
outer[1],
);
draw_form(f, outer[2], state);
f.render_widget(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Color::DarkGray)),
outer[3],
);
draw_footer(f, outer[4], outer[5], state);
}
fn draw_title(f: &mut ratatui::Frame, area: Rect, state: &EditorState) {
let dirty_marker = if state.dirty { " [*] unsaved" } else { "" };
let title = Line::from(vec![
Span::styled(" kache config", Style::default().fg(Color::Cyan).bold()),
Span::styled(dirty_marker, Style::default().fg(Color::Yellow)),
]);
f.render_widget(Paragraph::new(title), area);
}
fn draw_form(f: &mut ratatui::Frame, area: Rect, state: &mut EditorState) {
let mut lines: Vec<Line> = Vec::new();
let mut cursor_line: u16 = 0;
for section in &state.sections {
lines.push(Line::from(Span::styled(
format!(" [{}]", section.label),
Style::default().fg(Color::Yellow).bold(),
)));
for i in section.fields.clone() {
let field = &state.fields[i];
let is_selected = i == state.cursor;
let is_editing = is_selected && state.mode == Mode::Editing;
if is_selected {
cursor_line = lines.len() as u16;
}
let mut spans = Vec::new();
if is_selected {
spans.push(Span::styled(" > ", Style::default().fg(Color::Cyan).bold()));
} else {
spans.push(Span::raw(" "));
}
let label = format!("{:<20}", field.label);
let label_style = if field.env_locked {
Style::default().fg(Color::DarkGray)
} else if is_selected {
Style::default().fg(Color::White).bold()
} else {
Style::default().fg(Color::Gray)
};
spans.push(Span::styled(label, label_style));
if is_editing {
let (before, after) = state.edit_buffer.split_at(state.edit_cursor);
spans.push(Span::styled(
before.to_string(),
Style::default().fg(Color::White),
));
if after.is_empty() {
spans.push(Span::styled("_", Style::default().fg(Color::Cyan).bold()));
} else {
let cursor_char = &after[..next_char_boundary(after, 0).min(after.len())];
let rest = &after[cursor_char.len()..];
spans.push(Span::styled(
cursor_char.to_string(),
Style::default().fg(Color::Black).bg(Color::Cyan),
));
spans.push(Span::styled(
rest.to_string(),
Style::default().fg(Color::White),
));
}
} else if field.kind == FieldKind::Bool {
let checked = field.value == "true"
|| (field.value.is_empty() && field.default_hint == "true");
let marker = if checked { "[x]" } else { "[ ]" };
let style = if field.env_locked {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled(marker, style));
} else {
let value_style = if field.env_locked {
Style::default().fg(Color::DarkGray)
} else if field.is_placeholder() {
Style::default().fg(Color::DarkGray).italic()
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled(field.display_value().to_string(), value_style));
}
if field.env_locked {
let env_text = if let Some(ref val) = field.env_value {
format!(" (env: {}={})", field.env_var, val)
} else {
format!(" (env: {})", field.env_var)
};
spans.push(Span::styled(
env_text,
Style::default().fg(Color::DarkGray).italic(),
));
}
lines.push(Line::from(spans));
if let Some(ref err) = field.validation_error {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("^ {err}"), Style::default().fg(Color::Red)),
]));
}
}
lines.push(Line::from("")); }
let visible_rows = area.height;
if visible_rows > 0 {
if cursor_line < state.scroll_offset {
state.scroll_offset = cursor_line.saturating_sub(1);
}
if cursor_line >= state.scroll_offset + visible_rows {
state.scroll_offset = cursor_line.saturating_sub(visible_rows - 2);
}
}
let paragraph = Paragraph::new(lines).scroll((state.scroll_offset, 0));
f.render_widget(paragraph, area);
}
fn draw_footer(f: &mut ratatui::Frame, path_area: Rect, keys_area: Rect, state: &EditorState) {
let path = resolve_config_path();
let path_str = path.to_string_lossy();
let mut path_spans = vec![Span::styled(
format!(" Config: {path_str}"),
Style::default().fg(Color::DarkGray),
)];
if let Some(ref status) = state.status {
let color = if status.contains("Saved") {
Color::Green
} else if status.contains("failed")
|| status.contains("Fix")
|| status.contains("Unsaved")
|| status.contains("overwritten")
{
Color::Yellow
} else {
Color::White
};
path_spans.push(Span::styled(
format!(" {status}"),
Style::default().fg(color).bold(),
));
}
f.render_widget(Paragraph::new(Line::from(path_spans)), path_area);
let keys = match state.mode {
Mode::Navigate => {
" \u{2191}\u{2193}/jk: navigate Enter: edit Space: toggle Tab: section s: save q: quit"
}
Mode::Editing => " Type to edit Enter: commit Esc: discard",
Mode::ConfirmQuit => " y: quit without saving n/Esc: cancel",
Mode::ConfirmSave => " y: overwrite file n/Esc: cancel",
};
f.render_widget(
Paragraph::new(Span::styled(keys, Style::default().fg(Color::DarkGray))),
keys_area,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{PathsFileConfig, PlannerFileConfig};
fn absolute_test_path() -> String {
std::env::temp_dir()
.join("kache")
.to_string_lossy()
.into_owned()
}
fn empty_env() -> EnvOverrides {
EnvOverrides {
fallback: false,
key_salt: false,
cc_extra_allowlist_flags: false,
disabled: false,
local_only: false,
remote_readonly: false,
cache_dir: false,
max_size: false,
cache_executables: false,
clean_incremental: false,
preserve_incremental: false,
adaptive_incremental: false,
s3_bucket: false,
s3_endpoint: false,
s3_region: false,
s3_prefix: false,
s3_profile: false,
s3_user_agent: false,
}
}
#[test]
fn test_build_fields_count() {
let config = FileConfig::default();
let fields = build_fields(&config, &empty_env());
assert_eq!(fields.len(), 21);
let sections = build_sections();
assert_eq!(sections[0].fields, 0..3);
assert_eq!(sections[1].fields, 3..10);
assert_eq!(sections[2].fields, 10..19);
assert_eq!(sections[3].fields, 19..21);
}
#[test]
fn test_build_fields_preserves_values() {
let config = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
local_store: Some("~/my/cache".to_string()),
local_max_size: Some("100GiB".to_string()),
adaptive_incremental: Some(false),
remote: Some(RemoteFileConfig {
_type: Some("s3".to_string()),
bucket: Some("my-bucket".to_string()),
user_agent: Some("custom-agent/1.0".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let fields = build_fields(&config, &empty_env());
assert_eq!(fields[0].value, "~/my/cache");
assert_eq!(fields[1].value, "100GiB");
let adaptive = fields
.iter()
.find(|field| field.key == "adaptive_incremental")
.unwrap();
assert_eq!(adaptive.value, "false");
assert_eq!(adaptive.default_hint, "true");
let user_agent = fields
.iter()
.find(|field| field.key == "s3_user_agent")
.unwrap();
assert_eq!(user_agent.value, "custom-agent/1.0");
}
#[test]
fn test_adaptive_incremental_env_lock() {
let env = EnvOverrides {
adaptive_incremental: true,
..empty_env()
};
let fields = build_fields(&FileConfig::default(), &env);
let adaptive = fields
.iter()
.find(|field| field.key == "adaptive_incremental")
.unwrap();
assert!(adaptive.env_locked);
assert_eq!(adaptive.env_var, "KACHE_ADAPTIVE_INCREMENTAL");
assert_eq!(adaptive.default_hint, "true");
}
#[test]
fn test_validate_size_field() {
let field = FormField {
key: "max_size",
label: "Max store size",
kind: FieldKind::Size,
value: "not-a-size".to_string(),
env_var: "",
env_value: None,
default_hint: "",
validation_error: None,
env_locked: false,
};
assert!(validate_field(&field).is_some());
let valid = FormField {
value: "50GiB".to_string(),
..field
};
assert!(validate_field(&valid).is_none());
}
#[test]
fn test_validate_usize_field() {
let field = FormField {
key: "event_log_keep_lines",
label: "Event log lines",
kind: FieldKind::Usize,
value: "abc".to_string(),
env_var: "",
env_value: None,
default_hint: "",
validation_error: None,
env_locked: false,
};
assert!(validate_field(&field).is_some());
let valid = FormField {
value: "1000".to_string(),
..field
};
assert!(validate_field(&valid).is_none());
}
#[test]
fn test_validate_empty_is_ok() {
let field = FormField {
key: "max_size",
label: "Max store size",
kind: FieldKind::Size,
value: String::new(),
env_var: "",
env_value: None,
default_hint: "",
validation_error: None,
env_locked: false,
};
assert!(validate_field(&field).is_none());
}
#[test]
fn test_cross_validation_s3() {
let config = FileConfig::default();
let mut fields = build_fields(&config, &empty_env());
if let Some(f) = fields.iter_mut().find(|f| f.key == "s3_endpoint") {
f.value = "https://s3.example.com".to_string();
}
let errors = validate_cross_field(&fields);
assert!(
errors
.iter()
.any(|(_, message)| message.contains("Backend type required"))
);
assert!(
errors
.iter()
.any(|(_, message)| message.contains("Bucket required"))
);
}
#[test]
fn test_cross_validation_accepts_env_locked_s3_bucket() {
let config = FileConfig::default();
let mut fields = build_fields(&config, &empty_env());
fields
.iter_mut()
.find(|field| field.key == "remote_type")
.unwrap()
.value = "s3".to_string();
let bucket = fields
.iter_mut()
.find(|field| field.key == "s3_bucket")
.unwrap();
bucket.env_locked = true;
bucket.env_value = Some("ambient-bucket".to_string());
let errors = validate_cross_field(&fields);
assert!(
!errors
.iter()
.any(|(_, message)| message.contains("Bucket required")),
"{errors:?}"
);
}
#[test]
fn test_cross_validation_filesystem_requires_path_and_rejects_s3_fields() {
let config = FileConfig::default();
let mut fields = build_fields(&config, &empty_env());
fields
.iter_mut()
.find(|field| field.key == "remote_type")
.unwrap()
.value = "filesystem".to_string();
fields
.iter_mut()
.find(|field| field.key == "s3_bucket")
.unwrap()
.value = "wrong-backend".to_string();
let errors = validate_cross_field(&fields);
assert!(
errors
.iter()
.any(|(_, message)| message.contains("Filesystem path required"))
);
assert!(
errors
.iter()
.any(|(_, message)| message.contains("conflicts with filesystem"))
);
}
#[test]
fn test_cross_validation_s3_rejects_filesystem_fields() {
let config = FileConfig::default();
let mut fields = build_fields(&config, &empty_env());
for (key, value) in [
("remote_type", "s3"),
("s3_bucket", "bucket"),
("fs_path", "/var/cache/kache"),
] {
fields
.iter_mut()
.find(|field| field.key == key)
.unwrap()
.value = value.to_string();
}
let errors = validate_cross_field(&fields);
assert!(
errors
.iter()
.any(|(_, message)| message.contains("conflicts with S3"))
);
}
#[test]
fn test_cross_validation_rejects_unknown_backend() {
let config = FileConfig::default();
let mut fields = build_fields(&config, &empty_env());
fields
.iter_mut()
.find(|field| field.key == "remote_type")
.unwrap()
.value = "azure".to_string();
let errors = validate_cross_field(&fields);
assert!(
errors
.iter()
.any(|(_, message)| message.contains("must be \"s3\" or \"filesystem\""))
);
}
#[test]
fn test_build_fields_infers_legacy_s3_type() {
let config = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
bucket: Some("legacy-bucket".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let fields = build_fields(&config, &empty_env());
assert_eq!(
fields
.iter()
.find(|field| field.key == "remote_type")
.unwrap()
.value,
"s3"
);
}
#[test]
fn test_filesystem_prefix_ignores_legacy_s3_env_lock() {
let config = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some(absolute_test_path()),
prefix: Some("shared".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let mut env = empty_env();
env.s3_prefix = true;
let fields = build_fields(&config, &env);
let prefix = fields
.iter()
.find(|field| field.key == "remote_prefix")
.unwrap();
assert!(!prefix.env_locked);
assert_eq!(prefix.env_var, "KACHE_S3_PREFIX");
}
#[test]
fn test_filesystem_ignores_all_legacy_s3_env_locks() {
let config = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some(absolute_test_path()),
prefix: Some("shared".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let mut env = empty_env();
env.s3_bucket = true;
env.s3_endpoint = true;
env.s3_region = true;
env.s3_prefix = true;
env.s3_profile = true;
env.s3_user_agent = true;
let fields = build_fields(&config, &env);
for key in [
"s3_bucket",
"s3_endpoint",
"s3_region",
"s3_profile",
"s3_user_agent",
"remote_prefix",
] {
let field = fields.iter().find(|field| field.key == key).unwrap();
assert!(!field.env_locked, "{key}");
if key == "remote_prefix" {
assert_eq!(field.env_var, "KACHE_S3_PREFIX");
} else {
assert!(field.env_var.starts_with("KACHE_S3_"), "{key}");
}
}
assert!(validate_cross_field(&fields).is_empty());
}
#[test]
fn test_filesystem_validation_ignores_ambient_s3_values() {
let config = FileConfig::default();
let mut fields = build_fields(&config, &empty_env());
fields
.iter_mut()
.find(|field| field.key == "remote_type")
.unwrap()
.value = "filesystem".to_string();
fields
.iter_mut()
.find(|field| field.key == "fs_path")
.unwrap()
.value = absolute_test_path();
for key in [
"s3_bucket",
"s3_endpoint",
"s3_region",
"s3_profile",
"s3_user_agent",
] {
let field = fields.iter_mut().find(|field| field.key == key).unwrap();
field.env_locked = true;
field.env_value = Some("ambient".to_string());
}
assert!(validate_cross_field(&fields).is_empty());
}
#[test]
fn test_env_only_legacy_s3_does_not_require_persisted_type() {
let config = FileConfig::default();
let mut fields = build_fields(&config, &empty_env());
let bucket = fields
.iter_mut()
.find(|field| field.key == "s3_bucket")
.unwrap();
bucket.env_var = "KACHE_S3_BUCKET";
bucket.env_value = Some("ambient-bucket".to_string());
bucket.env_locked = true;
assert!(validate_cross_field(&fields).is_empty());
}
#[test]
fn test_backend_switch_recomputes_s3_env_locks() {
let config = FileConfig::default();
let mut fields = build_fields(&config, &empty_env());
for key in ["s3_bucket", "remote_prefix"] {
let field = fields.iter_mut().find(|field| field.key == key).unwrap();
field.env_var = if key == "s3_bucket" {
"KACHE_S3_BUCKET"
} else {
"KACHE_S3_PREFIX"
};
field.env_value = Some("ambient".to_string());
field.env_locked = true;
}
fields
.iter_mut()
.find(|field| field.key == "remote_type")
.unwrap()
.value = "filesystem".to_string();
refresh_remote_env_scope(&mut fields);
assert!(
fields
.iter()
.filter(|field| matches!(field.key, "s3_bucket" | "remote_prefix"))
.all(|field| !field.env_locked)
);
fields
.iter_mut()
.find(|field| field.key == "remote_type")
.unwrap()
.value = "s3".to_string();
refresh_remote_env_scope(&mut fields);
assert!(
fields
.iter()
.filter(|field| matches!(field.key, "s3_bucket" | "remote_prefix"))
.all(|field| field.env_locked)
);
}
#[test]
fn test_filesystem_validation_rejects_relative_paths() {
let config = FileConfig::default();
let mut fields = build_fields(&config, &empty_env());
for (key, value) in [
("remote_type", "filesystem"),
("fs_path", "relative/cache"),
("fs_atomic_write_dir", "relative/staging"),
] {
fields
.iter_mut()
.find(|field| field.key == key)
.unwrap()
.value = value.to_string();
}
let errors = validate_cross_field(&fields);
assert_eq!(
errors
.iter()
.filter(|(_, message)| message.contains("must be absolute"))
.count(),
2
);
}
#[test]
fn test_remote_prefix_validation_rejects_only_unnormalizable_paths() {
let config = FileConfig::default();
let validate = |prefix: &str| {
let mut fields = build_fields(&config, &empty_env());
for (key, value) in [
("remote_type", "s3"),
("s3_bucket", "bucket"),
("remote_prefix", prefix),
] {
fields
.iter_mut()
.find(|field| field.key == key)
.unwrap()
.value = value.to_string();
}
validate_cross_field(&fields)
.iter()
.any(|(_, message)| message.contains("path segments"))
};
for prefix in ["a/../b", r"a\b", ".."] {
assert!(validate(prefix), "{prefix:?} must be rejected");
}
for prefix in [" artifacts", "/artifacts", "artifacts/", "a//b"] {
assert!(!validate(prefix), "{prefix:?} must be accepted");
}
}
#[test]
fn editor_save_preserves_legacy_settings_while_applying_visible_edits() {
let source = include_str!("../tests/fixtures/config_editor_legacy.toml");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, source).unwrap();
let (loaded, existed) = Config::load_raw_file_config_from(&path);
assert!(existed);
let mut state = initial_editor_state(&loaded, existed, &empty_env());
for (key, value) in [
("cache_dir", "~/new-store"),
("max_size", "55GiB"),
("cache_executables", "false"),
("clean_incremental", "true"),
("preserve_incremental", "false"),
("adaptive_incremental", "true"),
("exclude", "target/**, generated/**"),
("event_log_max_size", "21MiB"),
("event_log_keep_lines", "903"),
("fallback", "custom-wrapper"),
("key_salt", "new-salt"),
("s3_bucket", "new-bucket"),
] {
state
.fields
.iter_mut()
.find(|f| f.key == key)
.unwrap()
.value = value.to_string();
}
run_all_validation(&mut state.fields);
assert!(!has_validation_errors(&state.fields));
do_save_to(&mut state, &path);
assert_eq!(state.status.as_deref(), Some("Saved!"));
let mut expected: toml::Value = toml::from_str(source).unwrap();
let updates: toml::Table = toml::from_str(
r#"
local_store = "~/new-store"
local_max_size = "55GiB"
cache_executables = false
clean_incremental = true
preserve_incremental = false
adaptive_incremental = true
exclude = ["target/**", "generated/**"]
event_log_max_size = "21MiB"
event_log_keep_lines = 903
fallback = "custom-wrapper"
key_salt = "new-salt"
"#,
)
.unwrap();
let cache = expected["cache"].as_table_mut().unwrap();
cache.extend(updates);
let remote = cache["remote"].as_table_mut().unwrap();
remote.insert("type".into(), "s3".into());
remote.insert("bucket".into(), "new-bucket".into());
let user_agent = remote.remove("user-agent").unwrap();
remote.insert("user_agent".into(), user_agent);
let saved: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
saved, expected,
"unedited settings must survive a real file save"
);
}
#[test]
fn editor_save_clears_visible_values_without_clearing_hidden_settings() {
let source = include_str!("../tests/fixtures/config_editor_legacy.toml");
let loaded: FileConfig = toml::from_str(source).unwrap();
let mut state = initial_editor_state(&loaded, true, &empty_env());
for field in &mut state.fields {
field.value.clear();
}
run_all_validation(&mut state.fields);
assert!(!has_validation_errors(&state.fields));
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
do_save_to(&mut state, &path);
assert_eq!(state.status.as_deref(), Some("Saved!"));
let mut expected: toml::Value = toml::from_str(source).unwrap();
let cache = expected["cache"].as_table_mut().unwrap();
for key in [
"local_store",
"local_max_size",
"cache_executables",
"clean_incremental",
"preserve_incremental",
"adaptive_incremental",
"exclude",
"event_log_max_size",
"event_log_keep_lines",
"fallback",
"key_salt",
"remote",
] {
assert!(cache.remove(key).is_some(), "fixture must set {key}");
}
let saved: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
saved, expected,
"clearing form fields must restore their defaults"
);
}
#[test]
fn test_fields_to_file_config_roundtrip() {
let original = FileConfig {
cc: None,
paths: Some(PathsFileConfig {
base_dirs: Some(vec!["/snap".to_string(), "/var/lib/flatpak".to_string()]),
}),
workspace: None,
cache: Some(CacheFileConfig {
bypass_env: None,
bypass_argv: None,
bypass_crates: None,
local_only: None,
remote_readonly: None,
volumes: Some(
[("D:".to_string(), "D:/kache-store".to_string())]
.into_iter()
.collect(),
),
modified_input_guard: None,
input_predictions: None,
local_hit_daemon: None,
windows_hardlink: None,
auto_gc: None,
gc_evict_shared: Some(true),
storage_layout_advice: None,
heartbeat_secs: None,
explain_miss: None,
ignore_env: None,
fallback: None,
key_salt: None,
path_only_env_vars: Some(vec!["BUILDCONFIG_RS".to_string()]),
incremental_crates: Some(vec!["tap_lib".to_string()]),
key_env_vars: Some(vec!["BOLTFFI_*".to_string()]),
local_store: Some("~/cache".to_string()),
runtime_dir: Some("~/runtime".to_string()),
local_max_size: Some("50GiB".to_string()),
planner: Some(PlannerFileConfig {
endpoint: Some("https://planner.example.com".to_string()),
timeout_ms: Some(2000),
token: Some("secret-token".to_string()),
}),
cache_executables: Some(true),
clean_incremental: Some(false),
preserve_incremental: Some(true),
adaptive_incremental: Some(false),
exclude: Some(vec![
"src/generated/**".to_string(),
"vendor/**".to_string(),
]),
event_log_max_size: Some("10MiB".to_string()),
event_log_keep_lines: Some(500),
compression_level: Some(3),
s3_concurrency: Some(8),
prefetch_enabled: Some(false),
remote_key_cache_refresh_secs: Some(900),
prefetch_max_keys: None,
prefetch_max_bytes: None,
prefetch_deadline_secs: None,
min_store_compile_ms: Some(750),
gc_max_age_hours: Some(72),
scheduler: Some(false),
daemon_idle_timeout_secs: Some(600),
s3_pool_idle_secs: None,
remote_restore_timeout_secs: Some(180),
remote_negative_ttl_secs: Some(45),
remote: Some(RemoteFileConfig {
_type: Some("s3".to_string()),
bucket: Some("test-bucket".to_string()),
endpoint: Some("https://s3.example.com".to_string()),
region: Some("eu-west-1".to_string()),
prefix: Some("my-project".to_string()),
profile: Some("build-account".to_string()),
user_agent: Some("custom-ua/1.0".to_string()),
path: None,
atomic_write_dir: None,
}),
}),
};
let fields = build_fields(&original, &empty_env());
let reconstructed = fields_to_file_config(&fields, &original);
let cache = reconstructed.cache.as_ref().unwrap();
assert_eq!(
reconstructed
.paths
.as_ref()
.and_then(|paths| paths.base_dirs.as_deref()),
Some(&["/snap".to_string(), "/var/lib/flatpak".to_string()][..])
);
assert_eq!(cache.local_store.as_deref(), Some("~/cache"));
assert_eq!(cache.runtime_dir.as_deref(), Some("~/runtime"));
assert_eq!(cache.prefetch_enabled, Some(false));
assert_eq!(cache.remote_key_cache_refresh_secs, Some(900));
assert_eq!(cache.remote_restore_timeout_secs, Some(180));
assert_eq!(cache.remote_negative_ttl_secs, Some(45));
assert_eq!(cache.min_store_compile_ms, Some(750));
assert_eq!(cache.gc_max_age_hours, Some(72));
assert_eq!(cache.scheduler, Some(false));
assert_eq!(cache.gc_evict_shared, Some(true));
assert_eq!(cache.daemon_idle_timeout_secs, Some(600));
let planner = cache.planner.as_ref().expect("planner preserved on save");
assert_eq!(
planner.endpoint.as_deref(),
Some("https://planner.example.com")
);
assert_eq!(planner.token.as_deref(), Some("secret-token"));
assert_eq!(planner.timeout_ms, Some(2000));
assert_eq!(
cache.exclude.as_deref(),
Some(&["src/generated/**".to_string(), "vendor/**".to_string()][..])
);
assert_eq!(
cache.path_only_env_vars.as_deref(),
Some(&["BUILDCONFIG_RS".to_string()][..])
);
assert_eq!(
cache.key_env_vars.as_deref(),
Some(&["BOLTFFI_*".to_string()][..])
);
assert_eq!(cache.local_max_size.as_deref(), Some("50GiB"));
assert_eq!(cache.cache_executables, Some(true));
assert_eq!(cache.clean_incremental, Some(false));
assert_eq!(cache.preserve_incremental, Some(true));
assert_eq!(cache.adaptive_incremental, Some(false));
assert_eq!(cache.event_log_max_size.as_deref(), Some("10MiB"));
assert_eq!(cache.event_log_keep_lines, Some(500));
let remote = cache.remote.as_ref().unwrap();
assert_eq!(remote._type.as_deref(), Some("s3"));
assert_eq!(remote.bucket.as_deref(), Some("test-bucket"));
assert_eq!(remote.endpoint.as_deref(), Some("https://s3.example.com"));
assert_eq!(remote.region.as_deref(), Some("eu-west-1"));
assert_eq!(remote.prefix.as_deref(), Some("my-project"));
assert_eq!(remote.profile.as_deref(), Some("build-account"));
assert!(remote.path.is_none());
assert!(remote.atomic_write_dir.is_none());
}
#[test]
fn initial_editor_state_carries_form_less_settings_through_a_save() {
let loaded = FileConfig {
workspace: Some(
toml::from_str(
"[[extra_inputs]]\ncrates=['macro-provider']\ninputs=['shared/value.txt']\n",
)
.unwrap(),
),
cache: Some(CacheFileConfig {
key_env_vars: Some(vec!["BOLTFFI_*".to_string()]),
path_only_env_vars: Some(vec!["BUILDCONFIG_RS".to_string()]),
daemon_idle_timeout_secs: Some(600),
scheduler: Some(false),
..Default::default()
}),
..Default::default()
};
let state = initial_editor_state(&loaded, true, &empty_env());
assert_eq!(
state
.file_config
.cache
.as_ref()
.unwrap()
.key_env_vars
.as_deref(),
Some(&["BOLTFFI_*".to_string()][..])
);
assert_eq!(
state
.file_config
.cache
.as_ref()
.unwrap()
.path_only_env_vars
.as_deref(),
Some(&["BUILDCONFIG_RS".to_string()][..])
);
assert_eq!(
state
.file_config
.cache
.as_ref()
.unwrap()
.daemon_idle_timeout_secs,
Some(600)
);
assert_eq!(
state.file_config.cache.as_ref().unwrap().scheduler,
Some(false)
);
let saved = fields_to_file_config(&state.fields, &state.file_config);
let cache = saved.cache.as_ref().expect("cache section written");
assert_eq!(
cache.key_env_vars.as_deref(),
Some(&["BOLTFFI_*".to_string()][..])
);
assert_eq!(
cache.path_only_env_vars.as_deref(),
Some(&["BUILDCONFIG_RS".to_string()][..])
);
assert_eq!(cache.daemon_idle_timeout_secs, Some(600));
assert_eq!(saved.workspace, loaded.workspace);
}
#[test]
fn test_fields_to_file_config_filesystem_roundtrip() {
let original = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
prefix: Some("shared/artifacts".to_string()),
path: Some("/var/cache/kache".to_string()),
atomic_write_dir: Some("/var/cache/kache-tmp".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let fields = build_fields(&original, &empty_env());
let reconstructed = fields_to_file_config(&fields, &original);
let remote = reconstructed
.cache
.as_ref()
.and_then(|cache| cache.remote.as_ref())
.unwrap();
assert_eq!(remote._type.as_deref(), Some("filesystem"));
assert_eq!(remote.path.as_deref(), Some("/var/cache/kache"));
assert_eq!(
remote.atomic_write_dir.as_deref(),
Some("/var/cache/kache-tmp")
);
assert_eq!(remote.prefix.as_deref(), Some("shared/artifacts"));
assert!(remote.bucket.is_none());
assert!(remote.endpoint.is_none());
assert!(remote.region.is_none());
assert!(remote.profile.is_none());
}
#[test]
fn test_fields_to_file_config_only_user_agent_persists_remote() {
let config = FileConfig::default();
let mut fields = build_fields(&config, &empty_env());
let ua_field = fields
.iter_mut()
.find(|field| field.key == "s3_user_agent")
.unwrap();
ua_field.value = "custom-agent/1.0".to_string();
let result = fields_to_file_config(&fields, &config);
let remote = result
.cache
.as_ref()
.and_then(|cache| cache.remote.as_ref())
.expect("remote section must be created when only s3_user_agent is populated");
assert_eq!(remote.user_agent.as_deref(), Some("custom-agent/1.0"));
assert!(remote._type.is_none());
assert!(remote.bucket.is_none());
assert!(remote.endpoint.is_none());
assert!(remote.region.is_none());
assert!(remote.profile.is_none());
assert!(remote.path.is_none());
assert!(remote.atomic_write_dir.is_none());
}
#[test]
fn test_fields_to_file_config_empty_omits_remote() {
let config = FileConfig::default();
let fields = build_fields(&config, &empty_env());
let result = fields_to_file_config(&fields, &config);
assert!(result.cache.as_ref().unwrap().remote.is_none());
}
#[test]
fn test_bool_toggle() {
let config = FileConfig::default();
let mut fields = build_fields(&config, &empty_env());
let idx = fields
.iter()
.position(|f| f.key == "cache_executables")
.unwrap();
assert!(fields[idx].value.is_empty());
fields[idx].value = "true".to_string();
assert_eq!(fields[idx].value, "true");
fields[idx].value = "false".to_string();
assert_eq!(fields[idx].value, "false");
}
#[test]
fn test_env_locked_field() {
let env = EnvOverrides {
cache_dir: true,
..empty_env()
};
let config = FileConfig::default();
let fields = build_fields(&config, &env);
assert!(fields[0].env_locked); assert!(!fields[1].env_locked); }
#[test]
fn test_disabled_is_always_env_locked() {
let config = FileConfig::default();
let fields = build_fields(&config, &empty_env());
let disabled = fields.iter().find(|f| f.key == "disabled").unwrap();
assert!(disabled.env_locked);
}
fn editor() -> EditorState {
let fields = build_fields(&FileConfig::default(), &empty_env());
EditorState {
fields,
sections: build_sections(),
cursor: 0,
mode: Mode::Navigate,
edit_buffer: String::new(),
edit_cursor: 0,
dirty: false,
status: None,
file_had_content: false,
has_saved_once: false,
scroll_offset: 0,
file_config: FileConfig::default(),
}
}
fn key(state: &mut EditorState, code: KeyCode) -> Action {
handle_key(state, code, KeyModifiers::NONE)
}
fn first_text_field(state: &EditorState) -> usize {
state
.fields
.iter()
.position(|f| !f.env_locked && f.kind == FieldKind::Text)
.expect("a text field exists")
}
fn first_bool_field(state: &EditorState) -> usize {
state
.fields
.iter()
.position(|f| !f.env_locked && f.kind == FieldKind::Bool)
.expect("a bool field exists")
}
#[test]
fn prev_next_char_boundary_handle_multibyte() {
let s = "aéb";
assert_eq!(next_char_boundary(s, 0), 1); assert_eq!(next_char_boundary(s, 1), 3); assert_eq!(prev_char_boundary(s, 3), 1); assert_eq!(prev_char_boundary(s, 1), 0);
assert_eq!(prev_char_boundary(s, 0), 0); }
#[test]
fn navigate_down_and_up_moves_cursor() {
let mut s = editor();
let start = s.cursor;
key(&mut s, KeyCode::Down);
assert_ne!(s.cursor, start, "down should move the cursor");
key(&mut s, KeyCode::Up);
assert_eq!(s.cursor, start, "up should return to start");
}
#[test]
fn enter_opens_editing_for_a_text_field() {
let mut s = editor();
s.cursor = first_text_field(&s);
let action = key(&mut s, KeyCode::Enter);
assert!(matches!(action, Action::Continue));
assert_eq!(s.mode, Mode::Editing);
assert_eq!(s.edit_buffer, s.fields[s.cursor].value);
}
#[test]
fn editing_inserts_commits_and_marks_dirty() {
let mut s = editor();
s.cursor = first_text_field(&s);
key(&mut s, KeyCode::Enter); for ch in "hello".chars() {
handle_editing(&mut s, KeyCode::Char(ch));
}
assert_eq!(s.edit_buffer, "hello");
assert_eq!(s.edit_cursor, 5);
handle_editing(&mut s, KeyCode::Backspace);
assert_eq!(s.edit_buffer, "hell");
handle_editing(&mut s, KeyCode::Home);
assert_eq!(s.edit_cursor, 0);
handle_editing(&mut s, KeyCode::End);
assert_eq!(s.edit_cursor, 4);
handle_editing(&mut s, KeyCode::Enter);
assert_eq!(s.mode, Mode::Navigate);
assert_eq!(s.fields[s.cursor].value, "hell");
assert!(s.dirty);
}
#[test]
fn editing_delete_and_arrow_keys_move_and_remove() {
let mut s = editor();
s.cursor = first_text_field(&s);
key(&mut s, KeyCode::Enter);
for ch in "abcd".chars() {
handle_editing(&mut s, KeyCode::Char(ch));
}
handle_editing(&mut s, KeyCode::Left);
handle_editing(&mut s, KeyCode::Left);
assert_eq!(s.edit_cursor, 2);
handle_editing(&mut s, KeyCode::Delete);
assert_eq!(s.edit_buffer, "abd");
assert_eq!(s.edit_cursor, 2);
handle_editing(&mut s, KeyCode::Right);
assert_eq!(s.edit_cursor, 3);
}
#[test]
fn tab_and_backtab_jump_between_sections() {
let mut s = editor();
let section_of = |st: &EditorState| {
st.sections
.iter()
.position(|sec| sec.fields.contains(&st.cursor))
.unwrap()
};
let start = section_of(&s);
key(&mut s, KeyCode::Tab);
assert_ne!(section_of(&s), start, "Tab should jump to another section");
assert!(!s.fields[s.cursor].env_locked);
key(&mut s, KeyCode::BackTab);
assert_eq!(
section_of(&s),
start,
"BackTab returns to the first section"
);
}
#[test]
fn try_save_blocks_on_validation_errors() {
let mut s = editor();
let idx = s
.fields
.iter()
.position(|f| !f.env_locked && f.kind == FieldKind::Usize)
.expect("a usize field exists");
s.fields[idx].value = "not-a-number".to_string();
key(&mut s, KeyCode::Char('s'));
assert_eq!(s.mode, Mode::Navigate, "save must not proceed");
assert_eq!(
s.status.as_deref(),
Some("Fix validation errors before saving")
);
}
#[test]
fn try_save_prompts_confirm_when_overwriting_existing_file() {
let mut s = editor();
s.file_had_content = true;
s.has_saved_once = false;
key(&mut s, KeyCode::Char('s'));
assert_eq!(s.mode, Mode::ConfirmSave);
assert!(
s.status.as_deref().unwrap().contains("overwritten"),
"should prompt before overwriting: {:?}",
s.status
);
}
#[test]
fn editing_esc_discards_without_committing() {
let mut s = editor();
s.cursor = first_text_field(&s);
let original = s.fields[s.cursor].value.clone();
key(&mut s, KeyCode::Enter);
handle_editing(&mut s, KeyCode::Char('x'));
handle_editing(&mut s, KeyCode::Esc);
assert_eq!(s.mode, Mode::Navigate);
assert_eq!(s.fields[s.cursor].value, original, "edit was discarded");
}
#[test]
fn space_toggles_a_bool_field() {
let mut s = editor();
s.cursor = first_bool_field(&s);
let before = s.fields[s.cursor].value.clone();
key(&mut s, KeyCode::Char(' '));
assert_ne!(s.fields[s.cursor].value, before, "bool should toggle");
assert!(s.dirty);
}
#[test]
fn quit_when_clean_returns_quit() {
let mut s = editor();
assert!(matches!(key(&mut s, KeyCode::Char('q')), Action::Quit));
}
#[test]
fn quit_when_dirty_prompts_confirm_then_y_quits() {
let mut s = editor();
s.dirty = true;
let action = key(&mut s, KeyCode::Char('q'));
assert!(matches!(action, Action::Continue));
assert_eq!(s.mode, Mode::ConfirmQuit);
assert!(matches!(
handle_confirm_quit(&mut s, KeyCode::Char('n')),
Action::Continue
));
assert_eq!(s.mode, Mode::Navigate);
s.mode = Mode::ConfirmQuit;
assert!(matches!(
handle_confirm_quit(&mut s, KeyCode::Char('y')),
Action::Quit
));
}
#[test]
fn do_save_to_writes_config_and_marks_saved() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested/config.toml");
let mut s = editor();
s.dirty = true;
let idx = first_text_field(&s);
s.fields[idx].value = "/tmp/kache-cache-xyz".to_string();
do_save_to(&mut s, &path);
assert!(
path.exists(),
"config file should be written (parent created)"
);
assert_eq!(s.status.as_deref(), Some("Saved!"));
assert!(!s.dirty, "save clears the dirty flag");
assert!(s.has_saved_once);
let body = std::fs::read_to_string(&path).unwrap();
let _: crate::config::FileConfig = toml::from_str(&body).expect("valid TOML written");
}
#[test]
fn do_save_to_reports_failure_on_unwritable_path() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"i am a file").unwrap();
let path = blocker.join("config.toml");
let mut s = editor();
s.dirty = true;
do_save_to(&mut s, &path);
assert!(
s.status.as_deref().unwrap_or("").starts_with("Save failed"),
"expected a save-failure status, got {:?}",
s.status
);
assert!(s.dirty, "a failed save must not clear the dirty flag");
}
#[test]
fn handle_confirm_save_cancel_returns_to_navigate() {
for code in [KeyCode::Char('n'), KeyCode::Esc, KeyCode::Char('x')] {
let mut s = editor();
s.mode = Mode::ConfirmSave;
s.status = Some("Existing file will be overwritten. Proceed? (y/n)".to_string());
let action = handle_confirm_save(&mut s, code);
assert!(matches!(action, Action::Continue));
assert_eq!(
s.mode,
Mode::Navigate,
"key {code:?} should cancel to navigate"
);
assert!(s.status.is_none(), "cancel clears the prompt for {code:?}");
}
}
#[test]
fn draw_renders_in_every_mode_without_panicking() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
for mode in [
Mode::Navigate,
Mode::Editing,
Mode::ConfirmQuit,
Mode::ConfirmSave,
] {
let mut state = editor();
state.mode = mode;
if mode == Mode::Editing {
state.cursor = first_text_field(&state);
state.edit_buffer = "editing value".to_string();
state.edit_cursor = state.edit_buffer.len();
}
state.status = Some("a status line".to_string());
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
terminal
.draw(|frame| draw(frame, &mut state))
.expect("config editor draw should succeed");
let buffer = terminal.backend().buffer().clone();
let rendered: String = buffer.content().iter().map(|c| c.symbol()).collect();
assert!(
rendered.trim().chars().any(|c| !c.is_whitespace()),
"mode {mode:?} should render visible content"
);
}
}
#[test]
fn draw_renders_validation_errors() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut state = editor();
run_all_validation(&mut state.fields);
if let Some(field) = state.fields.iter_mut().find(|f| !f.env_locked) {
field.value = "definitely not a valid size".to_string();
field.validation_error = Some("invalid size".to_string());
}
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
terminal
.draw(|frame| draw(frame, &mut state))
.expect("draw with validation error should succeed");
let buffer = terminal.backend().buffer().clone();
let rendered: String = buffer.content().iter().map(|c| c.symbol()).collect();
assert!(rendered.trim().chars().any(|c| !c.is_whitespace()));
}
#[test]
fn draw_renders_editing_cursor_mid_string() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut state = editor();
state.mode = Mode::Editing;
state.cursor = first_text_field(&state);
state.edit_buffer = "abcdef".to_string();
state.edit_cursor = 2;
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
terminal
.draw(|frame| draw(frame, &mut state))
.expect("draw mid-cursor edit should succeed");
let rendered: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(rendered.contains("abcdef") || rendered.contains("ab"));
}
#[test]
fn draw_autoscrolls_when_cursor_is_above_viewport() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut state = editor();
state.cursor = 0; state.scroll_offset = 100;
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &mut state))
.expect("draw with stale scroll should succeed");
assert!(
state.scroll_offset < 100,
"a cursor above the viewport must pull scroll_offset back, got {}",
state.scroll_offset
);
}
}