Skip to content

Commit e7782d0

Browse files
authored
Add global config path configuration (#164)
* First pass * Rework downloader, add tilde expander * Cleanup
1 parent 23cc2bc commit e7782d0

File tree

14 files changed

+416
-149
lines changed

14 files changed

+416
-149
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
- Add Windows ARM64 release artifacts
44
- Move dictionary cache directory to platform-specific data directories instead of /tmp
5+
- Allow overriding the global `codebook.toml` path via LSP initialization option `globalConfigPath`
56

67
[0.3.18]
78

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,14 @@ If quickfix code actions are not showing up for specific languages, ensure your
4343
},
4444
```
4545

46-
To enable DEBUG logs, add this to your settings.json:
46+
To enable DEBUG logs or change the global config path, add this to your settings.json:
4747

4848
```json
4949
"lsp": {
5050
"codebook": {
5151
"initialization_options": {
52-
"logLevel": "debug"
52+
"logLevel": "debug",
53+
"globalConfigPath": "~/.config/codebook/codebook.toml"
5354
}
5455
}
5556
},
@@ -212,6 +213,8 @@ The global configuration applies to all projects by default. Location depends on
212213
- **Linux/macOS**: `$XDG_CONFIG_HOME/codebook/codebook.toml` or `~/.config/codebook/codebook.toml`
213214
- **Windows**: `%APPDATA%\codebook\codebook.toml` or `%APPDATA%\Roaming\codebook\codebook.toml`
214215

216+
You can override this location if you sync your config elsewhere by providing `initializationOptions.globalConfigPath` from your LSP client. When no override is provided, the OS-specific default above is used.
217+
215218
### Project Configuration
216219

217220
Project-specific configuration is loaded from either `codebook.toml` or `.codebook.toml` in the project root. Codebook searches for this file starting from the current directory and moving up to parent directories.

crates/codebook-config/spec.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ The data model for configuration settings:
4444
- Linux/macOS: `$XDG_CONFIG_HOME/codebook/codebook.toml` if XDG_CONFIG_HOME is set
4545
- Linux/macOS fallback: `~/.config/codebook/codebook.toml`
4646
- Windows: `%APPDATA%\codebook\codebook.toml`
47+
- **Custom Overrides**:
48+
- Consumers may call `CodebookConfigFile::load_with_global_config` to supply an explicit global config path (used by `codebook-lsp` when an LSP client provides `initializationOptions.globalConfigPath`).
4749

4850
- **Configuration Precedence**:
4951
- Project configuration overrides global configuration

crates/codebook-config/src/helpers.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,26 @@ pub(crate) fn min_word_length(settings: &ConfigSettings) -> usize {
131131
settings.min_word_length
132132
}
133133

134+
pub(crate) fn expand_tilde<P: AsRef<Path>>(path_user_input: P) -> Option<PathBuf> {
135+
let p = path_user_input.as_ref();
136+
if !p.starts_with("~") {
137+
return Some(p.to_path_buf());
138+
}
139+
if p == Path::new("~") {
140+
return dirs::home_dir();
141+
}
142+
dirs::home_dir().map(|mut h| {
143+
if h == Path::new("/") {
144+
// Corner case: `h` root directory;
145+
// don't prepend extra `/`, just drop the tilde.
146+
p.strip_prefix("~").unwrap().to_path_buf()
147+
} else {
148+
h.push(p.strip_prefix("~/").unwrap());
149+
h
150+
}
151+
})
152+
}
153+
134154
#[cfg(test)]
135155
mod tests {
136156
use super::*;

crates/codebook-config/src/lib.rs

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod helpers;
22
mod settings;
33
mod watched_file;
4+
use crate::helpers::expand_tilde;
45
use crate::settings::ConfigSettings;
56
use crate::watched_file::WatchedFile;
67
use log::debug;
@@ -72,24 +73,40 @@ impl Default for CodebookConfigFile {
7273
impl CodebookConfigFile {
7374
/// Load configuration by searching for both global and project-specific configs
7475
pub fn load(current_dir: Option<&Path>) -> Result<Self, io::Error> {
76+
Self::load_with_global_config(current_dir, None)
77+
}
78+
79+
/// Load configuration with an explicit global config override.
80+
pub fn load_with_global_config(
81+
current_dir: Option<&Path>,
82+
global_config_path: Option<PathBuf>,
83+
) -> Result<Self, io::Error> {
7584
debug!("Initializing CodebookConfig");
7685

7786
if let Some(current_dir) = current_dir {
7887
let current_dir = Path::new(current_dir);
79-
Self::load_configs(current_dir)
88+
Self::load_configs(current_dir, global_config_path)
8089
} else {
8190
let current_dir = env::current_dir()?;
82-
Self::load_configs(&current_dir)
91+
Self::load_configs(&current_dir, global_config_path)
8392
}
8493
}
8594

8695
/// Load both global and project configuration
87-
fn load_configs(start_dir: &Path) -> Result<Self, io::Error> {
96+
fn load_configs(
97+
start_dir: &Path,
98+
global_config_override: Option<PathBuf>,
99+
) -> Result<Self, io::Error> {
88100
let config = Self::default();
89101
let mut inner = config.inner.write().unwrap();
90102

91103
// First, try to load global config
92-
if let Some(global_path) = Self::find_global_config_path() {
104+
let global_config_path = match global_config_override {
105+
Some(path) => Some(path.to_path_buf()),
106+
None => Self::find_global_config_path(),
107+
};
108+
109+
if let Some(global_path) = global_config_path {
93110
let global_config = WatchedFile::new(Some(global_path.clone()));
94111

95112
if global_path.exists() {
@@ -321,6 +338,10 @@ impl CodebookConfigFile {
321338
None => return Ok(()),
322339
};
323340

341+
#[cfg(not(windows))]
342+
let global_config_path = expand_tilde(global_config_path)
343+
.expect("Failed to expand tilde in: {global_config_path}");
344+
324345
let settings = match inner.global_config.content() {
325346
Some(settings) => settings,
326347
None => return Ok(()),
@@ -806,14 +827,40 @@ mod tests {
806827
"#
807828
)?;
808829

809-
let config = CodebookConfigFile::load_configs(&sub_sub_dir)?;
830+
let config = CodebookConfigFile::load_configs(&sub_sub_dir, None)?;
810831
assert!(config.snapshot().words.contains(&"testword".to_string()));
811832

812833
// Check that the config file path is stored
813834
assert_eq!(config.project_config_path(), Some(config_path));
814835
Ok(())
815836
}
816837

838+
#[test]
839+
fn test_global_config_override_is_used() -> Result<(), io::Error> {
840+
let temp_dir = TempDir::new().unwrap();
841+
let workspace_dir = temp_dir.path().join("workspace");
842+
fs::create_dir_all(&workspace_dir)?;
843+
let custom_global_dir = temp_dir.path().join("global");
844+
fs::create_dir_all(&custom_global_dir)?;
845+
let override_path = custom_global_dir.join("codebook.toml");
846+
847+
fs::write(
848+
&override_path,
849+
r#"
850+
words = ["customword"]
851+
"#,
852+
)?;
853+
854+
let config = CodebookConfigFile::load_with_global_config(
855+
Some(workspace_dir.as_path()),
856+
Some(override_path.clone()),
857+
)?;
858+
859+
assert_eq!(config.global_config_path(), Some(override_path));
860+
assert!(config.is_allowed_word("customword"));
861+
Ok(())
862+
}
863+
817864
#[test]
818865
fn test_should_ignore_path() {
819866
let config = CodebookConfigFile::default();
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
use log::LevelFilter;
2+
use serde::Deserialize;
3+
use serde::de::Deserializer;
4+
use serde_json::Value;
5+
use std::path::PathBuf;
6+
7+
fn default_log_level() -> LevelFilter {
8+
LevelFilter::Info
9+
}
10+
11+
fn deserialize_log_level<'de, D>(deserializer: D) -> Result<LevelFilter, D::Error>
12+
where
13+
D: Deserializer<'de>,
14+
{
15+
let s: Option<String> = Option::deserialize(deserializer)?;
16+
match s.as_deref() {
17+
Some("trace") => Ok(LevelFilter::Trace),
18+
Some("debug") => Ok(LevelFilter::Debug),
19+
Some("warn") => Ok(LevelFilter::Warn),
20+
Some("error") => Ok(LevelFilter::Error),
21+
_ => Ok(LevelFilter::Info),
22+
}
23+
}
24+
25+
fn deserialize_global_config_path<'de, D>(deserializer: D) -> Result<Option<PathBuf>, D::Error>
26+
where
27+
D: Deserializer<'de>,
28+
{
29+
let s: Option<String> = Option::deserialize(deserializer)?;
30+
match s {
31+
Some(path) => {
32+
let trimmed = path.trim();
33+
if trimmed.is_empty() {
34+
Ok(None)
35+
} else {
36+
Ok(Some(PathBuf::from(trimmed)))
37+
}
38+
}
39+
None => Ok(None),
40+
}
41+
}
42+
43+
#[derive(Debug, Deserialize)]
44+
#[serde(rename_all = "camelCase")]
45+
pub(crate) struct ClientInitializationOptions {
46+
#[serde(
47+
default = "default_log_level",
48+
deserialize_with = "deserialize_log_level"
49+
)]
50+
pub(crate) log_level: LevelFilter,
51+
#[serde(default, deserialize_with = "deserialize_global_config_path")]
52+
pub(crate) global_config_path: Option<PathBuf>,
53+
}
54+
55+
impl Default for ClientInitializationOptions {
56+
fn default() -> Self {
57+
ClientInitializationOptions {
58+
log_level: default_log_level(),
59+
global_config_path: None,
60+
}
61+
}
62+
}
63+
64+
impl ClientInitializationOptions {
65+
pub(crate) fn from_value(options_value: Option<Value>) -> Self {
66+
match options_value {
67+
None => ClientInitializationOptions::default(),
68+
Some(value) => match serde_json::from_value(value) {
69+
Ok(options) => options,
70+
Err(err) => {
71+
log::error!(
72+
"Failed to deserialize client initialization options. Using default: {}",
73+
err
74+
);
75+
ClientInitializationOptions::default()
76+
}
77+
},
78+
}
79+
}
80+
}
81+
82+
#[cfg(test)]
83+
mod tests {
84+
use super::*;
85+
#[test]
86+
fn test_default() {
87+
let default_options = ClientInitializationOptions::default();
88+
assert_eq!(default_options.log_level, LevelFilter::Info);
89+
}
90+
91+
#[test]
92+
fn test_custom() {
93+
let custom_options = ClientInitializationOptions {
94+
log_level: LevelFilter::Debug,
95+
..Default::default()
96+
};
97+
assert_eq!(custom_options.log_level, LevelFilter::Debug);
98+
}
99+
100+
#[test]
101+
fn test_json() {
102+
let json = r#"{"logLevel": "debug"}"#;
103+
let options: ClientInitializationOptions = serde_json::from_str(json).unwrap();
104+
assert_eq!(options.log_level, LevelFilter::Debug);
105+
}
106+
}

crates/codebook-lsp/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pub mod file_cache;
2+
mod init_options;
23
pub mod lsp;
34
pub mod lsp_logger;

0 commit comments

Comments
 (0)