settingspec 0.1.2__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {settingspec-0.1.2 → settingspec-0.3.0}/Cargo.lock +1 -1
- {settingspec-0.1.2 → settingspec-0.3.0}/Cargo.toml +1 -1
- {settingspec-0.1.2 → settingspec-0.3.0}/PKG-INFO +1 -1
- {settingspec-0.1.2 → settingspec-0.3.0}/src/cli.rs +61 -5
- {settingspec-0.1.2 → settingspec-0.3.0}/src/error.rs +6 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/model.rs +4 -0
- settingspec-0.3.0/src/parser.rs +341 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/profile.rs +4 -1
- settingspec-0.1.2/src/parser.rs +0 -186
- {settingspec-0.1.2 → settingspec-0.3.0}/LICENSE-APACHE +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/LICENSE-MIT +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/README.md +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/pyproject.toml +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/envfile.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/filter.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/generator/common.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/generator/env.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/generator/javascript.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/generator/json.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/generator/lua.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/generator/mod.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/generator/python.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/generator/toml.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/generator/tree.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/generator/typescript.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/generator/yaml.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/lib.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/main.rs +0 -0
- {settingspec-0.1.2 → settingspec-0.3.0}/src/resolver.rs +0 -0
|
@@ -23,6 +23,8 @@ pub struct Cli {
|
|
|
23
23
|
|
|
24
24
|
#[derive(Subcommand, Debug)]
|
|
25
25
|
pub enum Commands {
|
|
26
|
+
/// Initializes settingspec.toml in current directory
|
|
27
|
+
Init,
|
|
26
28
|
/// Generates all configured export files and/or writes formatted output to stdout
|
|
27
29
|
Export,
|
|
28
30
|
/// Validates configuration syntax, profile completeness, and environment variables
|
|
@@ -34,19 +36,71 @@ pub enum Commands {
|
|
|
34
36
|
},
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
pub const INIT_CONFIG: &str = r#"[spec]
|
|
40
|
+
profile.options = ["local", "prod"]
|
|
41
|
+
profile.default = "local" # Switch via $SETTINGSPEC_PROFILE
|
|
42
|
+
|
|
43
|
+
[settings]
|
|
44
|
+
key.local.val = "local"
|
|
45
|
+
key.prod.env = "KEY" # Load from $KEY when active profile is "prod"
|
|
46
|
+
"#;
|
|
47
|
+
|
|
48
|
+
pub fn init_config() -> Result<()> {
|
|
49
|
+
let config_path = Path::new("settingspec.toml");
|
|
50
|
+
if config_path.exists() {
|
|
51
|
+
return Ok(());
|
|
52
|
+
}
|
|
53
|
+
std::fs::write(config_path, INIT_CONFIG)?;
|
|
54
|
+
Ok(())
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
pub struct ExecutionContext {
|
|
38
58
|
pub doc: SettingSpecDocument,
|
|
39
59
|
pub active_profile: Option<String>,
|
|
40
60
|
pub resolved_settings: Vec<ResolvedSetting>,
|
|
41
61
|
pub sourced_env: HashMap<String, String>,
|
|
62
|
+
pub project_root: PathBuf,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pub fn find_project_root(start_dir: &Path) -> Option<PathBuf> {
|
|
66
|
+
let mut current = if start_dir.is_relative() {
|
|
67
|
+
match std::env::current_dir() {
|
|
68
|
+
Ok(cwd) => cwd.join(start_dir),
|
|
69
|
+
Err(_) => start_dir.to_path_buf(),
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
start_dir.to_path_buf()
|
|
73
|
+
};
|
|
74
|
+
if let Ok(canon) = current.canonicalize() {
|
|
75
|
+
current = canon;
|
|
76
|
+
} else {
|
|
77
|
+
current = normalize_path(¤t);
|
|
78
|
+
}
|
|
79
|
+
loop {
|
|
80
|
+
let config = current.join("settingspec.toml");
|
|
81
|
+
if config.is_file() {
|
|
82
|
+
return Some(current);
|
|
83
|
+
}
|
|
84
|
+
if !current.pop() {
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
None
|
|
42
89
|
}
|
|
43
90
|
|
|
44
91
|
pub fn load_and_resolve() -> Result<ExecutionContext> {
|
|
45
|
-
let
|
|
46
|
-
|
|
47
|
-
|
|
92
|
+
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
93
|
+
let project_root = find_project_root(¤t_dir).ok_or(SettingSpecError::ConfigNotFound)?;
|
|
94
|
+
|
|
95
|
+
if let Ok(current_canon) = current_dir.canonicalize() {
|
|
96
|
+
if project_root != current_canon {
|
|
97
|
+
std::env::set_current_dir(&project_root)?;
|
|
98
|
+
}
|
|
99
|
+
} else if project_root != current_dir {
|
|
100
|
+
std::env::set_current_dir(&project_root)?;
|
|
48
101
|
}
|
|
49
102
|
|
|
103
|
+
let config_path = Path::new("settingspec.toml");
|
|
50
104
|
let content = std::fs::read_to_string(config_path)?;
|
|
51
105
|
let doc = parse_config_str(&content)?;
|
|
52
106
|
|
|
@@ -64,6 +118,7 @@ pub fn load_and_resolve() -> Result<ExecutionContext> {
|
|
|
64
118
|
active_profile,
|
|
65
119
|
resolved_settings,
|
|
66
120
|
sourced_env,
|
|
121
|
+
project_root,
|
|
67
122
|
})
|
|
68
123
|
}
|
|
69
124
|
|
|
@@ -71,6 +126,7 @@ pub fn run_cli() -> Result<()> {
|
|
|
71
126
|
let cli = Cli::parse();
|
|
72
127
|
|
|
73
128
|
match cli.command {
|
|
129
|
+
Commands::Init => init_config(),
|
|
74
130
|
Commands::Check => {
|
|
75
131
|
// Check only validates config syntax, profiles, env vars without creating files
|
|
76
132
|
load_and_resolve()?;
|
|
@@ -312,8 +368,8 @@ pub fn auto_append_gitignore(ctx: &ExecutionContext) -> Result<()> {
|
|
|
312
368
|
return Ok(());
|
|
313
369
|
}
|
|
314
370
|
|
|
315
|
-
let current_dir =
|
|
316
|
-
let git_root = match find_git_root(
|
|
371
|
+
let current_dir = &ctx.project_root;
|
|
372
|
+
let git_root = match find_git_root(current_dir) {
|
|
317
373
|
Some(root) => root,
|
|
318
374
|
None => return Ok(()),
|
|
319
375
|
};
|
|
@@ -6,6 +6,7 @@ pub enum SettingSpecError {
|
|
|
6
6
|
InvalidToml(String),
|
|
7
7
|
MissingSettingsSection,
|
|
8
8
|
ReservedProfileInOptions,
|
|
9
|
+
ReservedKeywordConflict(String),
|
|
9
10
|
UnknownProfileInSettings(String),
|
|
10
11
|
ActiveProfileNotInOptions(String),
|
|
11
12
|
NoProfileSelected,
|
|
@@ -39,6 +40,11 @@ impl fmt::Display for SettingSpecError {
|
|
|
39
40
|
f,
|
|
40
41
|
"Reserved profile identifier 'default' MUST NOT be in spec.profile.options"
|
|
41
42
|
),
|
|
43
|
+
Self::ReservedKeywordConflict(seg) => write!(
|
|
44
|
+
f,
|
|
45
|
+
"Reserved directive keyword '{}' cannot be used as a setting key segment or profile name",
|
|
46
|
+
seg
|
|
47
|
+
),
|
|
42
48
|
Self::UnknownProfileInSettings(p) => write!(
|
|
43
49
|
f,
|
|
44
50
|
"Unknown profile '{}' used in [settings] (not in spec.profile.options)",
|
|
@@ -3,6 +3,10 @@ use serde::{Deserialize, Deserializer, Serialize};
|
|
|
3
3
|
use std::collections::BTreeMap;
|
|
4
4
|
use toml::Value as TomlValue;
|
|
5
5
|
|
|
6
|
+
pub fn is_reserved_directive_keyword(name: &str) -> bool {
|
|
7
|
+
matches!(name, "val" | "env" | "null" | "tags")
|
|
8
|
+
}
|
|
9
|
+
|
|
6
10
|
fn default_profile_key() -> String {
|
|
7
11
|
"SETTINGSPEC_PROFILE".to_string()
|
|
8
12
|
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
use crate::error::{Result, SettingSpecError};
|
|
2
|
+
use crate::model::*;
|
|
3
|
+
use serde::Deserialize;
|
|
4
|
+
use serde::de::IntoDeserializer;
|
|
5
|
+
use std::collections::BTreeMap;
|
|
6
|
+
use toml::Value as TomlValue;
|
|
7
|
+
|
|
8
|
+
#[derive(Debug, Deserialize)]
|
|
9
|
+
#[serde(deny_unknown_fields)]
|
|
10
|
+
pub struct SettingSpecSchema {
|
|
11
|
+
#[serde(default)]
|
|
12
|
+
pub spec: Option<SpecConfig>,
|
|
13
|
+
pub settings: Option<toml::Table>,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#[derive(Debug, Clone, Deserialize)]
|
|
17
|
+
#[serde(deny_unknown_fields)]
|
|
18
|
+
pub struct ProfileDeclarationSchema {
|
|
19
|
+
#[serde(default)]
|
|
20
|
+
pub val: Option<TomlValue>,
|
|
21
|
+
#[serde(default)]
|
|
22
|
+
pub env: Option<String>,
|
|
23
|
+
#[serde(default)]
|
|
24
|
+
pub null: Option<bool>,
|
|
25
|
+
#[serde(default)]
|
|
26
|
+
pub tags: Option<Vec<String>>,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pub fn parse_config_str(content: &str) -> Result<SettingSpecDocument> {
|
|
30
|
+
let schema: SettingSpecSchema =
|
|
31
|
+
toml::from_str(content).map_err(|e| SettingSpecError::InvalidToml(e.to_string()))?;
|
|
32
|
+
|
|
33
|
+
let spec = schema.spec.unwrap_or_default();
|
|
34
|
+
|
|
35
|
+
// Validate spec.profile.options
|
|
36
|
+
for opt in &spec.profile.options {
|
|
37
|
+
if opt == "default" {
|
|
38
|
+
return Err(SettingSpecError::ReservedProfileInOptions);
|
|
39
|
+
}
|
|
40
|
+
if is_reserved_directive_keyword(opt) {
|
|
41
|
+
return Err(SettingSpecError::ReservedKeywordConflict(opt.clone()));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if let Some(ref def) = spec.profile.default
|
|
46
|
+
&& is_reserved_directive_keyword(def)
|
|
47
|
+
{
|
|
48
|
+
return Err(SettingSpecError::ReservedKeywordConflict(def.clone()));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for prof in spec.envfile.profiles.keys() {
|
|
52
|
+
if is_reserved_directive_keyword(prof) {
|
|
53
|
+
return Err(SettingSpecError::ReservedKeywordConflict(prof.clone()));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parse [settings] (REQUIRED)
|
|
58
|
+
let settings_table = schema
|
|
59
|
+
.settings
|
|
60
|
+
.ok_or(SettingSpecError::MissingSettingsSection)?;
|
|
61
|
+
|
|
62
|
+
if settings_table.is_empty() {
|
|
63
|
+
return Err(SettingSpecError::MissingSettingsSection);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let mut settings: BTreeMap<String, Setting> = BTreeMap::new();
|
|
67
|
+
let mut current_path = Vec::new();
|
|
68
|
+
extract_settings(
|
|
69
|
+
&settings_table,
|
|
70
|
+
&mut current_path,
|
|
71
|
+
&spec.profile.options,
|
|
72
|
+
&mut settings,
|
|
73
|
+
)?;
|
|
74
|
+
|
|
75
|
+
if settings.is_empty() {
|
|
76
|
+
return Err(SettingSpecError::MissingSettingsSection);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Static validation: Strict Coverage Rule
|
|
80
|
+
if !spec.profile.options.is_empty() {
|
|
81
|
+
for (key, setting) in &settings {
|
|
82
|
+
if !setting.profiles.contains_key("default") {
|
|
83
|
+
for opt in &spec.profile.options {
|
|
84
|
+
if !setting.profiles.contains_key(opt) {
|
|
85
|
+
return Err(SettingSpecError::StrictCoverageFailed(
|
|
86
|
+
key.clone(),
|
|
87
|
+
opt.clone(),
|
|
88
|
+
));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
Ok(SettingSpecDocument { spec, settings })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fn has_known_directive(table: &toml::map::Map<String, TomlValue>) -> bool {
|
|
99
|
+
table.contains_key("val")
|
|
100
|
+
|| table.get("env").is_some_and(|v| !v.is_table())
|
|
101
|
+
|| table.get("null").is_some_and(|v| !v.is_table())
|
|
102
|
+
|| table.get("tags").is_some_and(|v| !v.is_table())
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fn is_profile_declaration_table(
|
|
106
|
+
table: &toml::map::Map<String, TomlValue>,
|
|
107
|
+
current_path: &[String],
|
|
108
|
+
profile_options: &[String],
|
|
109
|
+
) -> bool {
|
|
110
|
+
if current_path.len() < 2 {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if table.contains_key("default") || table.keys().any(|k| profile_options.contains(k)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let last_segment = current_path.last().unwrap();
|
|
119
|
+
|
|
120
|
+
if last_segment == "default" || profile_options.contains(last_segment) {
|
|
121
|
+
return has_known_directive(table)
|
|
122
|
+
|| (!table.is_empty() && !table.values().any(|v| v.is_table()));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if let Some(val_entry) = table.get("val") {
|
|
126
|
+
if let Some(sub_table) = val_entry.as_table()
|
|
127
|
+
&& has_known_directive(sub_table)
|
|
128
|
+
{
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if table.get("env").is_some_and(|v| !v.is_table())
|
|
135
|
+
|| table.get("null").is_some_and(|v| !v.is_table())
|
|
136
|
+
|| table.get("tags").is_some_and(|v| !v.is_table())
|
|
137
|
+
{
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if !table.is_empty() && !table.values().any(|v| v.is_table()) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
fn extract_settings(
|
|
149
|
+
table: &toml::map::Map<String, TomlValue>,
|
|
150
|
+
current_path: &mut Vec<String>,
|
|
151
|
+
profile_options: &[String],
|
|
152
|
+
settings: &mut BTreeMap<String, Setting>,
|
|
153
|
+
) -> Result<()> {
|
|
154
|
+
if is_profile_declaration_table(table, current_path, profile_options) {
|
|
155
|
+
let profile = current_path.last().unwrap().clone();
|
|
156
|
+
let key_segments = ¤t_path[..current_path.len() - 1];
|
|
157
|
+
let setting_key = key_segments.join(".");
|
|
158
|
+
|
|
159
|
+
// 1. Validate setting key segments for reserved keywords
|
|
160
|
+
for seg in key_segments {
|
|
161
|
+
if is_reserved_directive_keyword(seg) {
|
|
162
|
+
return Err(SettingSpecError::ReservedKeywordConflict(seg.clone()));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 2. Validate profile name for reserved keywords
|
|
167
|
+
if is_reserved_directive_keyword(&profile) {
|
|
168
|
+
return Err(SettingSpecError::ReservedKeywordConflict(profile));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 3. Validate profile options membership
|
|
172
|
+
if !profile_options.is_empty()
|
|
173
|
+
&& profile != "default"
|
|
174
|
+
&& !profile_options.contains(&profile)
|
|
175
|
+
{
|
|
176
|
+
return Err(SettingSpecError::UnknownProfileInSettings(profile));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 4. Validate directives: check for unknown directives
|
|
180
|
+
for k in table.keys() {
|
|
181
|
+
if !is_reserved_directive_keyword(k) {
|
|
182
|
+
return Err(SettingSpecError::InvalidDirective(
|
|
183
|
+
setting_key,
|
|
184
|
+
profile,
|
|
185
|
+
k.clone(),
|
|
186
|
+
));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 5. Deserialize profile declaration schema using Serde
|
|
191
|
+
let decl_schema: ProfileDeclarationSchema =
|
|
192
|
+
ProfileDeclarationSchema::deserialize(table.clone().into_deserializer())
|
|
193
|
+
.map_err(|e| SettingSpecError::InvalidToml(e.to_string()))?;
|
|
194
|
+
|
|
195
|
+
if let Some(null_val) = decl_schema.null
|
|
196
|
+
&& !null_val
|
|
197
|
+
{
|
|
198
|
+
return Err(SettingSpecError::InvalidNullValue(setting_key, profile));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if decl_schema.null == Some(true) && decl_schema.val.is_some() {
|
|
202
|
+
return Err(SettingSpecError::NullCoexistsWithVal(setting_key, profile));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if decl_schema.val.is_none() && decl_schema.env.is_none() && decl_schema.null != Some(true)
|
|
206
|
+
{
|
|
207
|
+
return Err(SettingSpecError::RequiredValueMissing(format!(
|
|
208
|
+
"{}.{}",
|
|
209
|
+
setting_key, profile
|
|
210
|
+
)));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let decl = ProfileDeclaration {
|
|
214
|
+
val: table.get("val").cloned(),
|
|
215
|
+
env: decl_schema.env,
|
|
216
|
+
null: decl_schema.null.unwrap_or(false),
|
|
217
|
+
tags: decl_schema.tags.unwrap_or_default(),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
let entry = settings
|
|
221
|
+
.entry(setting_key.clone())
|
|
222
|
+
.or_insert_with(|| Setting {
|
|
223
|
+
key: setting_key,
|
|
224
|
+
profiles: BTreeMap::new(),
|
|
225
|
+
});
|
|
226
|
+
entry.profiles.insert(profile, decl);
|
|
227
|
+
return Ok(());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Otherwise, descend into child tables
|
|
231
|
+
for (k, v) in table {
|
|
232
|
+
if let Some(sub_table) = v.as_table() {
|
|
233
|
+
current_path.push(k.clone());
|
|
234
|
+
extract_settings(sub_table, current_path, profile_options, settings)?;
|
|
235
|
+
current_path.pop();
|
|
236
|
+
} else {
|
|
237
|
+
let mut full = current_path.clone();
|
|
238
|
+
full.push(k.clone());
|
|
239
|
+
return Err(SettingSpecError::InvalidToml(format!(
|
|
240
|
+
"Invalid setting structure at '{}': expected profile sub-table",
|
|
241
|
+
full.join(".")
|
|
242
|
+
)));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
Ok(())
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#[cfg(test)]
|
|
250
|
+
mod tests {
|
|
251
|
+
use super::*;
|
|
252
|
+
|
|
253
|
+
#[test]
|
|
254
|
+
fn test_parse_reserved_directive_keywords_and_default_in_table_values() {
|
|
255
|
+
let toml_str = r#"
|
|
256
|
+
[settings]
|
|
257
|
+
key1.default.val = { val = 1 }
|
|
258
|
+
key2.default.val = { null = true }
|
|
259
|
+
key3.default.val = { tags = [1, 2, 3] }
|
|
260
|
+
key4.default.val = { env = "MY_VAR" }
|
|
261
|
+
key5.default.val = { default = { val = 1 } }
|
|
262
|
+
"#;
|
|
263
|
+
|
|
264
|
+
let doc = parse_config_str(toml_str).expect("Failed to parse config");
|
|
265
|
+
|
|
266
|
+
// key1: key1.default.val = { val = 1 }
|
|
267
|
+
let key1_decl = doc
|
|
268
|
+
.settings
|
|
269
|
+
.get("key1")
|
|
270
|
+
.unwrap()
|
|
271
|
+
.profiles
|
|
272
|
+
.get("default")
|
|
273
|
+
.unwrap();
|
|
274
|
+
assert!(!key1_decl.null);
|
|
275
|
+
assert!(key1_decl.env.is_none());
|
|
276
|
+
assert!(key1_decl.tags.is_empty());
|
|
277
|
+
let val1 = key1_decl.val.as_ref().unwrap().as_table().unwrap();
|
|
278
|
+
assert_eq!(val1.get("val").unwrap().as_integer(), Some(1));
|
|
279
|
+
|
|
280
|
+
// key2: key2.default.val = { null = true }
|
|
281
|
+
let key2_decl = doc
|
|
282
|
+
.settings
|
|
283
|
+
.get("key2")
|
|
284
|
+
.unwrap()
|
|
285
|
+
.profiles
|
|
286
|
+
.get("default")
|
|
287
|
+
.unwrap();
|
|
288
|
+
assert!(!key2_decl.null);
|
|
289
|
+
assert!(key2_decl.env.is_none());
|
|
290
|
+
assert!(key2_decl.tags.is_empty());
|
|
291
|
+
let val2 = key2_decl.val.as_ref().unwrap().as_table().unwrap();
|
|
292
|
+
assert_eq!(val2.get("null").unwrap().as_bool(), Some(true));
|
|
293
|
+
|
|
294
|
+
// key3: key3.default.val = { tags = [1, 2, 3] }
|
|
295
|
+
let key3_decl = doc
|
|
296
|
+
.settings
|
|
297
|
+
.get("key3")
|
|
298
|
+
.unwrap()
|
|
299
|
+
.profiles
|
|
300
|
+
.get("default")
|
|
301
|
+
.unwrap();
|
|
302
|
+
assert!(!key3_decl.null);
|
|
303
|
+
assert!(key3_decl.env.is_none());
|
|
304
|
+
assert!(key3_decl.tags.is_empty());
|
|
305
|
+
let val3 = key3_decl.val.as_ref().unwrap().as_table().unwrap();
|
|
306
|
+
let tags = val3.get("tags").unwrap().as_array().unwrap();
|
|
307
|
+
assert_eq!(tags.len(), 3);
|
|
308
|
+
assert_eq!(tags[0].as_integer(), Some(1));
|
|
309
|
+
assert_eq!(tags[1].as_integer(), Some(2));
|
|
310
|
+
assert_eq!(tags[2].as_integer(), Some(3));
|
|
311
|
+
|
|
312
|
+
// key4: key4.default.val = { env = "MY_VAR" }
|
|
313
|
+
let key4_decl = doc
|
|
314
|
+
.settings
|
|
315
|
+
.get("key4")
|
|
316
|
+
.unwrap()
|
|
317
|
+
.profiles
|
|
318
|
+
.get("default")
|
|
319
|
+
.unwrap();
|
|
320
|
+
assert!(!key4_decl.null);
|
|
321
|
+
assert!(key4_decl.env.is_none());
|
|
322
|
+
assert!(key4_decl.tags.is_empty());
|
|
323
|
+
let val4 = key4_decl.val.as_ref().unwrap().as_table().unwrap();
|
|
324
|
+
assert_eq!(val4.get("env").unwrap().as_str(), Some("MY_VAR"));
|
|
325
|
+
|
|
326
|
+
// key5: key5.default.val = { default = { val = 1 } }
|
|
327
|
+
let key5_decl = doc
|
|
328
|
+
.settings
|
|
329
|
+
.get("key5")
|
|
330
|
+
.unwrap()
|
|
331
|
+
.profiles
|
|
332
|
+
.get("default")
|
|
333
|
+
.unwrap();
|
|
334
|
+
assert!(!key5_decl.null);
|
|
335
|
+
assert!(key5_decl.env.is_none());
|
|
336
|
+
assert!(key5_decl.tags.is_empty());
|
|
337
|
+
let val5 = key5_decl.val.as_ref().unwrap().as_table().unwrap();
|
|
338
|
+
let nested_default = val5.get("default").unwrap().as_table().unwrap();
|
|
339
|
+
assert_eq!(nested_default.get("val").unwrap().as_integer(), Some(1));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
use crate::error::{Result, SettingSpecError};
|
|
2
|
-
use crate::model::ProfileConfig;
|
|
2
|
+
use crate::model::{ProfileConfig, is_reserved_directive_keyword};
|
|
3
3
|
|
|
4
4
|
/// Validates a trimmed, non-empty profile name against the configured
|
|
5
5
|
/// options (if any are configured; an empty list means any name is valid).
|
|
6
6
|
fn validate_profile(name: String, options: &[String]) -> Result<Option<String>> {
|
|
7
|
+
if is_reserved_directive_keyword(&name) {
|
|
8
|
+
return Err(SettingSpecError::ReservedKeywordConflict(name));
|
|
9
|
+
}
|
|
7
10
|
if !options.is_empty() && !options.contains(&name) {
|
|
8
11
|
return Err(SettingSpecError::ActiveProfileNotInOptions(name));
|
|
9
12
|
}
|
settingspec-0.1.2/src/parser.rs
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
use crate::error::{Result, SettingSpecError};
|
|
2
|
-
use crate::model::*;
|
|
3
|
-
use serde::Deserialize;
|
|
4
|
-
use serde::de::IntoDeserializer;
|
|
5
|
-
use std::collections::BTreeMap;
|
|
6
|
-
use toml::Value as TomlValue;
|
|
7
|
-
|
|
8
|
-
#[derive(Debug, Deserialize)]
|
|
9
|
-
#[serde(deny_unknown_fields)]
|
|
10
|
-
pub struct SettingSpecSchema {
|
|
11
|
-
#[serde(default)]
|
|
12
|
-
pub spec: Option<SpecConfig>,
|
|
13
|
-
pub settings: Option<toml::Table>,
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
#[derive(Debug, Clone, Deserialize)]
|
|
17
|
-
#[serde(deny_unknown_fields)]
|
|
18
|
-
pub struct ProfileDeclarationSchema {
|
|
19
|
-
#[serde(default)]
|
|
20
|
-
pub val: Option<TomlValue>,
|
|
21
|
-
#[serde(default)]
|
|
22
|
-
pub env: Option<String>,
|
|
23
|
-
#[serde(default)]
|
|
24
|
-
pub null: Option<bool>,
|
|
25
|
-
#[serde(default)]
|
|
26
|
-
pub tags: Option<Vec<String>>,
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
pub fn parse_config_str(content: &str) -> Result<SettingSpecDocument> {
|
|
30
|
-
let schema: SettingSpecSchema =
|
|
31
|
-
toml::from_str(content).map_err(|e| SettingSpecError::InvalidToml(e.to_string()))?;
|
|
32
|
-
|
|
33
|
-
let spec = schema.spec.unwrap_or_default();
|
|
34
|
-
|
|
35
|
-
// Validate spec.profile.options does not contain "default"
|
|
36
|
-
if spec.profile.options.iter().any(|o| o == "default") {
|
|
37
|
-
return Err(SettingSpecError::ReservedProfileInOptions);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Parse [settings] (REQUIRED)
|
|
41
|
-
let settings_table = schema
|
|
42
|
-
.settings
|
|
43
|
-
.ok_or(SettingSpecError::MissingSettingsSection)?;
|
|
44
|
-
|
|
45
|
-
if settings_table.is_empty() {
|
|
46
|
-
return Err(SettingSpecError::MissingSettingsSection);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
let mut settings: BTreeMap<String, Setting> = BTreeMap::new();
|
|
50
|
-
let mut current_path = Vec::new();
|
|
51
|
-
extract_settings(
|
|
52
|
-
&settings_table,
|
|
53
|
-
&mut current_path,
|
|
54
|
-
&spec.profile.options,
|
|
55
|
-
&mut settings,
|
|
56
|
-
)?;
|
|
57
|
-
|
|
58
|
-
if settings.is_empty() {
|
|
59
|
-
return Err(SettingSpecError::MissingSettingsSection);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Static validation: Strict Coverage Rule
|
|
63
|
-
if !spec.profile.options.is_empty() {
|
|
64
|
-
for (key, setting) in &settings {
|
|
65
|
-
if !setting.profiles.contains_key("default") {
|
|
66
|
-
for opt in &spec.profile.options {
|
|
67
|
-
if !setting.profiles.contains_key(opt) {
|
|
68
|
-
return Err(SettingSpecError::StrictCoverageFailed(
|
|
69
|
-
key.clone(),
|
|
70
|
-
opt.clone(),
|
|
71
|
-
));
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
Ok(SettingSpecDocument { spec, settings })
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
fn extract_settings(
|
|
82
|
-
table: &toml::map::Map<String, TomlValue>,
|
|
83
|
-
current_path: &mut Vec<String>,
|
|
84
|
-
profile_options: &[String],
|
|
85
|
-
settings: &mut BTreeMap<String, Setting>,
|
|
86
|
-
) -> Result<()> {
|
|
87
|
-
let has_directive = table
|
|
88
|
-
.keys()
|
|
89
|
-
.any(|k| matches!(k.as_str(), "val" | "env" | "null" | "tags"));
|
|
90
|
-
let last_is_profile = if let Some(last) = current_path.last() {
|
|
91
|
-
last == "default" || profile_options.contains(last)
|
|
92
|
-
} else {
|
|
93
|
-
false
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
if has_directive
|
|
97
|
-
|| (last_is_profile && current_path.len() >= 2 && !table.values().any(|v| v.is_table()))
|
|
98
|
-
{
|
|
99
|
-
// This is a profile declaration!
|
|
100
|
-
if current_path.len() < 2 {
|
|
101
|
-
return Err(SettingSpecError::InvalidToml(
|
|
102
|
-
"Malformed setting declaration: missing setting key or profile".into(),
|
|
103
|
-
));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
let profile = current_path.pop().unwrap();
|
|
107
|
-
let setting_key = current_path.join(".");
|
|
108
|
-
current_path.push(profile.clone());
|
|
109
|
-
|
|
110
|
-
// Validate profile name
|
|
111
|
-
if !profile_options.is_empty()
|
|
112
|
-
&& profile != "default"
|
|
113
|
-
&& !profile_options.contains(&profile)
|
|
114
|
-
{
|
|
115
|
-
return Err(SettingSpecError::UnknownProfileInSettings(profile));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Validate directives: check for unknown directives
|
|
119
|
-
for k in table.keys() {
|
|
120
|
-
if !matches!(k.as_str(), "val" | "env" | "null" | "tags") {
|
|
121
|
-
return Err(SettingSpecError::InvalidDirective(
|
|
122
|
-
setting_key,
|
|
123
|
-
profile,
|
|
124
|
-
k.clone(),
|
|
125
|
-
));
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Deserialize profile declaration schema using Serde
|
|
130
|
-
let decl_schema: ProfileDeclarationSchema =
|
|
131
|
-
ProfileDeclarationSchema::deserialize(table.clone().into_deserializer())
|
|
132
|
-
.map_err(|e| SettingSpecError::InvalidToml(e.to_string()))?;
|
|
133
|
-
|
|
134
|
-
if let Some(null_val) = decl_schema.null
|
|
135
|
-
&& !null_val
|
|
136
|
-
{
|
|
137
|
-
return Err(SettingSpecError::InvalidNullValue(setting_key, profile));
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if decl_schema.null == Some(true) && decl_schema.val.is_some() {
|
|
141
|
-
return Err(SettingSpecError::NullCoexistsWithVal(setting_key, profile));
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if decl_schema.val.is_none() && decl_schema.env.is_none() && decl_schema.null != Some(true)
|
|
145
|
-
{
|
|
146
|
-
return Err(SettingSpecError::RequiredValueMissing(format!(
|
|
147
|
-
"{}.{}",
|
|
148
|
-
setting_key, profile
|
|
149
|
-
)));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
let decl = ProfileDeclaration {
|
|
153
|
-
val: table.get("val").cloned(),
|
|
154
|
-
env: decl_schema.env,
|
|
155
|
-
null: decl_schema.null.unwrap_or(false),
|
|
156
|
-
tags: decl_schema.tags.unwrap_or_default(),
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
let entry = settings
|
|
160
|
-
.entry(setting_key.clone())
|
|
161
|
-
.or_insert_with(|| Setting {
|
|
162
|
-
key: setting_key,
|
|
163
|
-
profiles: BTreeMap::new(),
|
|
164
|
-
});
|
|
165
|
-
entry.profiles.insert(profile, decl);
|
|
166
|
-
return Ok(());
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Otherwise, descend into child tables
|
|
170
|
-
for (k, v) in table {
|
|
171
|
-
if let Some(sub_table) = v.as_table() {
|
|
172
|
-
current_path.push(k.clone());
|
|
173
|
-
extract_settings(sub_table, current_path, profile_options, settings)?;
|
|
174
|
-
current_path.pop();
|
|
175
|
-
} else {
|
|
176
|
-
let mut full = current_path.clone();
|
|
177
|
-
full.push(k.clone());
|
|
178
|
-
return Err(SettingSpecError::InvalidToml(format!(
|
|
179
|
-
"Invalid setting structure at '{}': expected profile sub-table",
|
|
180
|
-
full.join(".")
|
|
181
|
-
)));
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
Ok(())
|
|
186
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|