1use crate::config::{Config, ConfigError};
8use crate::partial_config::{Merge, PartialConfig, SessionConfig};
9use serde_json::Value;
10use std::path::{Path, PathBuf};
11
12fn strip_nulls(value: Value) -> Option<Value> {
20 match value {
21 Value::Null => None,
22 Value::Object(map) => {
23 let filtered: serde_json::Map<String, Value> = map
24 .into_iter()
25 .filter_map(|(k, v)| strip_nulls(v).map(|v| (k, v)))
26 .collect();
27 if filtered.is_empty() {
28 None
29 } else {
30 Some(Value::Object(filtered))
31 }
32 }
33 Value::Array(arr) => {
34 let filtered: Vec<Value> = arr.into_iter().filter_map(strip_nulls).collect();
35 Some(Value::Array(filtered))
36 }
37 other => Some(other),
38 }
39}
40
41fn strip_empty_defaults(value: Value) -> Option<Value> {
44 match value {
45 Value::Null => None,
46 Value::String(s) if s.is_empty() => None,
47 Value::Array(arr) if arr.is_empty() => None,
48 Value::Object(map) => {
49 let filtered: serde_json::Map<String, Value> = map
50 .into_iter()
51 .filter_map(|(k, v)| strip_empty_defaults(v).map(|v| (k, v)))
52 .collect();
53 if filtered.is_empty() {
54 None
55 } else {
56 Some(Value::Object(filtered))
57 }
58 }
59 Value::Array(arr) => {
60 let filtered: Vec<Value> = arr.into_iter().filter_map(strip_empty_defaults).collect();
61 if filtered.is_empty() {
62 None
63 } else {
64 Some(Value::Array(filtered))
65 }
66 }
67 other => Some(other),
68 }
69}
70
71fn set_json_pointer(root: &mut Value, pointer: &str, value: Value) {
74 if pointer.is_empty() || pointer == "/" {
75 *root = value;
76 return;
77 }
78
79 let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
80
81 let mut current = root;
82 for (i, part) in parts.iter().enumerate() {
83 if i == parts.len() - 1 {
84 if let Value::Object(map) = current {
86 map.insert(part.to_string(), value);
87 }
88 return;
89 }
90
91 if let Value::Object(map) = current {
93 if !map.contains_key(*part) {
94 map.insert(part.to_string(), Value::Object(Default::default()));
95 }
96 current = map.get_mut(*part).unwrap();
97 } else {
98 return; }
100 }
101}
102
103fn remove_json_pointer(root: &mut Value, pointer: &str) {
105 if pointer.is_empty() || pointer == "/" {
106 return;
107 }
108
109 let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
110
111 let mut current = root;
112 for (i, part) in parts.iter().enumerate() {
113 if i == parts.len() - 1 {
114 if let Value::Object(map) = current {
116 map.remove(*part);
117 }
118 return;
119 }
120
121 if let Value::Object(map) = current {
123 if let Some(next) = map.get_mut(*part) {
124 current = next;
125 } else {
126 return; }
128 } else {
129 return; }
131 }
132}
133
134pub const CURRENT_CONFIG_VERSION: u32 = 1;
141
142pub fn migrate_config(mut value: Value) -> Result<Value, ConfigError> {
144 let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
145
146 if version < 1 {
148 value = migrate_v0_to_v1(value)?;
149 }
150 Ok(value)
154}
155
156fn migrate_v0_to_v1(mut value: Value) -> Result<Value, ConfigError> {
159 if let Value::Object(ref mut map) = value {
160 map.insert("version".to_string(), Value::Number(1.into()));
162
163 if let Some(Value::Object(ref mut editor_map)) = map.get_mut("editor") {
165 if let Some(val) = editor_map.remove("tabSize") {
167 editor_map.entry("tab_size").or_insert(val);
168 }
169 if let Some(val) = editor_map.remove("lineNumbers") {
171 editor_map.entry("line_numbers").or_insert(val);
172 }
173 }
174 }
175 Ok(value)
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum ConfigLayer {
181 System,
183 User,
185 Project,
187 Session,
189}
190
191impl ConfigLayer {
192 pub fn precedence(self) -> u8 {
194 match self {
195 Self::System => 0,
196 Self::User => 1,
197 Self::Project => 2,
198 Self::Session => 3,
199 }
200 }
201}
202
203pub struct ConfigResolver {
208 dir_context: DirectoryContext,
209 working_dir: PathBuf,
210}
211
212impl ConfigResolver {
213 pub fn new(dir_context: DirectoryContext, working_dir: PathBuf) -> Self {
215 Self {
216 dir_context,
217 working_dir,
218 }
219 }
220
221 pub fn resolve(&self) -> Result<Config, ConfigError> {
228 let mut merged = self.load_session_layer()?.unwrap_or_default();
230
231 if let Some(project_partial) = self.load_project_layer()? {
233 tracing::debug!("Loaded project config layer");
234 merged.merge_from(&project_partial);
235 }
236
237 if let Some(platform_partial) = self.load_user_platform_layer()? {
239 tracing::debug!("Loaded user platform config layer");
240 merged.merge_from(&platform_partial);
241 }
242
243 if let Some(user_partial) = self.load_user_layer()? {
245 tracing::debug!("Loaded user config layer");
246 merged.merge_from(&user_partial);
247 }
248
249 Ok(merged.resolve())
251 }
252
253 pub fn user_config_path(&self) -> PathBuf {
255 self.dir_context.config_path()
256 }
257
258 pub fn project_config_path(&self) -> PathBuf {
261 let new_path = self.working_dir.join(".fresh").join("config.json");
262 if new_path.exists() {
263 return new_path;
264 }
265 let legacy_path = self.working_dir.join("config.json");
267 if legacy_path.exists() {
268 return legacy_path;
269 }
270 new_path
272 }
273
274 pub fn project_config_write_path(&self) -> PathBuf {
276 self.working_dir.join(".fresh").join("config.json")
277 }
278
279 pub fn session_config_path(&self) -> PathBuf {
281 self.working_dir.join(".fresh").join("session.json")
282 }
283
284 fn platform_config_filename() -> Option<&'static str> {
286 if cfg!(target_os = "linux") {
287 Some("config_linux.json")
288 } else if cfg!(target_os = "macos") {
289 Some("config_macos.json")
290 } else if cfg!(target_os = "windows") {
291 Some("config_windows.json")
292 } else {
293 None
294 }
295 }
296
297 pub fn user_platform_config_path(&self) -> Option<PathBuf> {
299 Self::platform_config_filename().map(|filename| self.dir_context.config_dir.join(filename))
300 }
301
302 pub fn load_user_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
304 self.load_layer_from_path(&self.user_config_path())
305 }
306
307 pub fn load_user_platform_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
309 if let Some(path) = self.user_platform_config_path() {
310 self.load_layer_from_path(&path)
311 } else {
312 Ok(None)
313 }
314 }
315
316 pub fn load_project_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
318 self.load_layer_from_path(&self.project_config_path())
319 }
320
321 pub fn load_session_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
323 self.load_layer_from_path(&self.session_config_path())
324 }
325
326 fn load_layer_from_path(&self, path: &Path) -> Result<Option<PartialConfig>, ConfigError> {
328 if !path.exists() {
329 return Ok(None);
330 }
331
332 let content = std::fs::read_to_string(path)
333 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
334
335 let value: Value = serde_json::from_str(&content)
337 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
338
339 let migrated = migrate_config(value)?;
341
342 let partial: PartialConfig = serde_json::from_value(migrated)
344 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
345
346 Ok(Some(partial))
347 }
348
349 pub fn save_to_layer(&self, config: &Config, layer: ConfigLayer) -> Result<(), ConfigError> {
351 if layer == ConfigLayer::System {
352 return Err(ConfigError::ValidationError(
353 "Cannot write to System layer".to_string(),
354 ));
355 }
356
357 let parent_partial = self.resolve_up_to_layer(layer)?;
359
360 let parent = PartialConfig::from(&parent_partial.resolve());
364
365 let current = PartialConfig::from(config);
367
368 let delta = diff_partial_config(¤t, &parent);
370
371 let path = match layer {
373 ConfigLayer::User => self.user_config_path(),
374 ConfigLayer::Project => self.project_config_write_path(),
375 ConfigLayer::Session => self.session_config_path(),
376 ConfigLayer::System => unreachable!(),
377 };
378
379 if let Some(parent_dir) = path.parent() {
381 std::fs::create_dir_all(parent_dir)
382 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
383 }
384
385 let existing: PartialConfig = if path.exists() {
388 let content = std::fs::read_to_string(&path)
389 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
390 serde_json::from_str(&content).unwrap_or_default()
391 } else {
392 PartialConfig::default()
393 };
394
395 let mut merged = delta;
397 merged.merge_from(&existing);
398
399 let merged_value = serde_json::to_value(&merged)
401 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
402 let stripped_nulls = strip_nulls(merged_value).unwrap_or(Value::Object(Default::default()));
403 let clean_merged =
404 strip_empty_defaults(stripped_nulls).unwrap_or(Value::Object(Default::default()));
405
406 let json = serde_json::to_string_pretty(&clean_merged)
407 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
408 std::fs::write(&path, json)
409 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
410
411 Ok(())
412 }
413
414 pub fn save_changes_to_layer(
419 &self,
420 changes: &std::collections::HashMap<String, serde_json::Value>,
421 deletions: &std::collections::HashSet<String>,
422 layer: ConfigLayer,
423 ) -> Result<(), ConfigError> {
424 if layer == ConfigLayer::System {
425 return Err(ConfigError::ValidationError(
426 "Cannot write to System layer".to_string(),
427 ));
428 }
429
430 let path = match layer {
432 ConfigLayer::User => self.user_config_path(),
433 ConfigLayer::Project => self.project_config_write_path(),
434 ConfigLayer::Session => self.session_config_path(),
435 ConfigLayer::System => unreachable!(),
436 };
437
438 if let Some(parent_dir) = path.parent() {
440 std::fs::create_dir_all(parent_dir)
441 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
442 }
443
444 let mut config_value: Value = if path.exists() {
446 let content = std::fs::read_to_string(&path)
447 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
448 serde_json::from_str(&content).unwrap_or(Value::Object(Default::default()))
449 } else {
450 Value::Object(Default::default())
451 };
452
453 for pointer in deletions {
455 remove_json_pointer(&mut config_value, pointer);
456 }
457
458 for (pointer, value) in changes {
460 set_json_pointer(&mut config_value, pointer, value.clone());
461 }
462
463 let _: PartialConfig = serde_json::from_value(config_value.clone()).map_err(|e| {
465 ConfigError::ValidationError(format!("Result config would be invalid: {}", e))
466 })?;
467
468 let stripped = strip_nulls(config_value).unwrap_or(Value::Object(Default::default()));
470 let clean = strip_empty_defaults(stripped).unwrap_or(Value::Object(Default::default()));
471
472 let json = serde_json::to_string_pretty(&clean)
473 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
474 std::fs::write(&path, json)
475 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
476
477 Ok(())
478 }
479
480 pub fn save_session(&self, session: &SessionConfig) -> Result<(), ConfigError> {
482 let path = self.session_config_path();
483
484 if let Some(parent_dir) = path.parent() {
486 std::fs::create_dir_all(parent_dir)
487 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
488 }
489
490 let json = serde_json::to_string_pretty(session)
491 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
492 std::fs::write(&path, json)
493 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
494
495 tracing::debug!("Saved session config to {}", path.display());
496 Ok(())
497 }
498
499 pub fn load_session(&self) -> Result<SessionConfig, ConfigError> {
501 match self.load_session_layer()? {
502 Some(partial) => Ok(SessionConfig::from(partial)),
503 None => Ok(SessionConfig::new()),
504 }
505 }
506
507 pub fn clear_session(&self) -> Result<(), ConfigError> {
509 let path = self.session_config_path();
510 if path.exists() {
511 std::fs::remove_file(&path)
512 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
513 tracing::debug!("Cleared session config at {}", path.display());
514 }
515 Ok(())
516 }
517
518 fn resolve_up_to_layer(&self, layer: ConfigLayer) -> Result<PartialConfig, ConfigError> {
521 let mut merged = PartialConfig::default();
522
523 if layer == ConfigLayer::Session {
529 if let Some(project) = self.load_project_layer()? {
531 merged = project;
532 }
533 if let Some(platform) = self.load_user_platform_layer()? {
534 merged.merge_from(&platform);
535 }
536 if let Some(user) = self.load_user_layer()? {
537 merged.merge_from(&user);
538 }
539 } else if layer == ConfigLayer::Project {
540 if let Some(platform) = self.load_user_platform_layer()? {
542 merged = platform;
543 }
544 if let Some(user) = self.load_user_layer()? {
545 merged.merge_from(&user);
546 }
547 }
548 Ok(merged)
551 }
552
553 pub fn get_layer_sources(
556 &self,
557 ) -> Result<std::collections::HashMap<String, ConfigLayer>, ConfigError> {
558 use std::collections::HashMap;
559
560 let mut sources: HashMap<String, ConfigLayer> = HashMap::new();
561
562 if let Some(session) = self.load_session_layer()? {
567 let json = serde_json::to_value(&session).unwrap_or_default();
568 collect_paths(&json, "", &mut |path| {
569 sources.insert(path, ConfigLayer::Session);
570 });
571 }
572
573 if let Some(project) = self.load_project_layer()? {
574 let json = serde_json::to_value(&project).unwrap_or_default();
575 collect_paths(&json, "", &mut |path| {
576 sources.entry(path).or_insert(ConfigLayer::Project);
577 });
578 }
579
580 if let Some(user) = self.load_user_layer()? {
581 let json = serde_json::to_value(&user).unwrap_or_default();
582 collect_paths(&json, "", &mut |path| {
583 sources.entry(path).or_insert(ConfigLayer::User);
584 });
585 }
586
587 Ok(sources)
590 }
591}
592
593fn collect_paths<F>(value: &Value, prefix: &str, collector: &mut F)
595where
596 F: FnMut(String),
597{
598 match value {
599 Value::Object(map) => {
600 for (key, val) in map {
601 let path = if prefix.is_empty() {
602 format!("/{}", key)
603 } else {
604 format!("{}/{}", prefix, key)
605 };
606 collect_paths(val, &path, collector);
607 }
608 }
609 Value::Null => {} _ => {
611 collector(prefix.to_string());
613 }
614 }
615}
616
617fn diff_partial_config(current: &PartialConfig, parent: &PartialConfig) -> PartialConfig {
620 let current_json = serde_json::to_value(current).unwrap_or_default();
622 let parent_json = serde_json::to_value(parent).unwrap_or_default();
623
624 let diff = json_diff(&parent_json, ¤t_json);
625
626 serde_json::from_value(diff).unwrap_or_default()
628}
629
630impl Config {
631 fn system_config_paths() -> Vec<PathBuf> {
636 let mut paths = Vec::with_capacity(2);
637
638 #[cfg(target_os = "macos")]
640 if let Some(home) = dirs::home_dir() {
641 let path = home.join(".config").join("fresh").join(Config::FILENAME);
642 if path.exists() {
643 paths.push(path);
644 }
645 }
646
647 if let Some(config_dir) = dirs::config_dir() {
649 let path = config_dir.join("fresh").join(Config::FILENAME);
650 if !paths.contains(&path) && path.exists() {
651 paths.push(path);
652 }
653 }
654
655 paths
656 }
657
658 fn config_search_paths(working_dir: &Path) -> Vec<PathBuf> {
666 let local = Self::local_config_path(working_dir);
667 let mut paths = Vec::with_capacity(3);
668
669 if local.exists() {
670 paths.push(local);
671 }
672
673 paths.extend(Self::system_config_paths());
674 paths
675 }
676
677 pub fn find_config_path(working_dir: &Path) -> Option<PathBuf> {
681 Self::config_search_paths(working_dir).into_iter().next()
682 }
683
684 pub fn load_with_layers(dir_context: &DirectoryContext, working_dir: &Path) -> Self {
689 let resolver = ConfigResolver::new(dir_context.clone(), working_dir.to_path_buf());
690 match resolver.resolve() {
691 Ok(config) => {
692 tracing::info!("Loaded layered config for {}", working_dir.display());
693 config
694 }
695 Err(e) => {
696 tracing::warn!("Failed to load layered config: {}, using defaults", e);
697 Self::default()
698 }
699 }
700 }
701
702 pub fn read_user_config_raw(working_dir: &Path) -> serde_json::Value {
710 for path in Self::config_search_paths(working_dir) {
711 if let Ok(contents) = std::fs::read_to_string(&path) {
712 match serde_json::from_str(&contents) {
713 Ok(value) => return value,
714 Err(e) => {
715 tracing::warn!("Failed to parse config from {}: {}", path.display(), e);
716 }
717 }
718 }
719 }
720 serde_json::Value::Object(serde_json::Map::new())
721 }
722}
723
724fn json_diff(defaults: &serde_json::Value, current: &serde_json::Value) -> serde_json::Value {
727 use serde_json::Value;
728
729 match (defaults, current) {
730 (Value::Object(def_map), Value::Object(cur_map)) => {
732 let mut result = serde_json::Map::new();
733
734 for (key, cur_val) in cur_map {
735 if let Some(def_val) = def_map.get(key) {
736 let diff = json_diff(def_val, cur_val);
738 if !is_empty_diff(&diff) {
740 result.insert(key.clone(), diff);
741 }
742 } else {
743 if let Some(stripped) = strip_empty_defaults(cur_val.clone()) {
745 result.insert(key.clone(), stripped);
746 }
747 }
748 }
749
750 Value::Object(result)
751 }
752 _ => {
754 if let Value::String(s) = current {
756 if s.is_empty() {
757 return Value::Object(serde_json::Map::new()); }
759 }
760 if defaults == current {
761 Value::Object(serde_json::Map::new()) } else {
763 current.clone()
764 }
765 }
766 }
767}
768
769fn is_empty_diff(value: &serde_json::Value) -> bool {
771 match value {
772 serde_json::Value::Object(map) => map.is_empty(),
773 _ => false,
774 }
775}
776
777#[derive(Debug, Clone)]
788pub struct DirectoryContext {
789 pub data_dir: std::path::PathBuf,
792
793 pub config_dir: std::path::PathBuf,
796
797 pub home_dir: Option<std::path::PathBuf>,
799
800 pub documents_dir: Option<std::path::PathBuf>,
802
803 pub downloads_dir: Option<std::path::PathBuf>,
805}
806
807impl DirectoryContext {
808 pub fn from_system() -> std::io::Result<Self> {
811 let data_dir = dirs::data_dir()
812 .ok_or_else(|| {
813 std::io::Error::new(
814 std::io::ErrorKind::NotFound,
815 "Could not determine data directory",
816 )
817 })?
818 .join("fresh");
819
820 #[allow(unused_mut)] let mut config_dir = dirs::config_dir()
822 .ok_or_else(|| {
823 std::io::Error::new(
824 std::io::ErrorKind::NotFound,
825 "Could not determine config directory",
826 )
827 })?
828 .join("fresh");
829
830 #[cfg(target_os = "macos")]
832 if let Some(home) = dirs::home_dir() {
833 config_dir = home.join(".config").join("fresh");
834 }
835
836 Ok(Self {
837 data_dir,
838 config_dir,
839 home_dir: dirs::home_dir(),
840 documents_dir: dirs::document_dir(),
841 downloads_dir: dirs::download_dir(),
842 })
843 }
844
845 pub fn for_testing(temp_dir: &std::path::Path) -> Self {
848 Self {
849 data_dir: temp_dir.join("data"),
850 config_dir: temp_dir.join("config"),
851 home_dir: Some(temp_dir.join("home")),
852 documents_dir: Some(temp_dir.join("documents")),
853 downloads_dir: Some(temp_dir.join("downloads")),
854 }
855 }
856
857 pub fn recovery_dir(&self) -> std::path::PathBuf {
859 self.data_dir.join("recovery")
860 }
861
862 pub fn sessions_dir(&self) -> std::path::PathBuf {
864 self.data_dir.join("sessions")
865 }
866
867 pub fn prompt_history_path(&self, history_name: &str) -> std::path::PathBuf {
871 let safe_name = history_name.replace(':', "_");
873 self.data_dir.join(format!("{}_history.json", safe_name))
874 }
875
876 pub fn search_history_path(&self) -> std::path::PathBuf {
878 self.prompt_history_path("search")
879 }
880
881 pub fn replace_history_path(&self) -> std::path::PathBuf {
883 self.prompt_history_path("replace")
884 }
885
886 pub fn goto_line_history_path(&self) -> std::path::PathBuf {
888 self.prompt_history_path("goto_line")
889 }
890
891 pub fn terminals_dir(&self) -> std::path::PathBuf {
893 self.data_dir.join("terminals")
894 }
895
896 pub fn terminal_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
898 let encoded = crate::session::encode_path_for_filename(working_dir);
899 self.terminals_dir().join(encoded)
900 }
901
902 pub fn config_path(&self) -> std::path::PathBuf {
904 self.config_dir.join(Config::FILENAME)
905 }
906
907 pub fn themes_dir(&self) -> std::path::PathBuf {
909 self.config_dir.join("themes")
910 }
911
912 pub fn grammars_dir(&self) -> std::path::PathBuf {
914 self.config_dir.join("grammars")
915 }
916
917 pub fn plugins_dir(&self) -> std::path::PathBuf {
919 self.config_dir.join("plugins")
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use tempfile::TempDir;
927
928 fn create_test_resolver() -> (TempDir, ConfigResolver) {
929 let temp_dir = TempDir::new().unwrap();
930 let dir_context = DirectoryContext::for_testing(temp_dir.path());
931 let working_dir = temp_dir.path().join("project");
932 std::fs::create_dir_all(&working_dir).unwrap();
933 let resolver = ConfigResolver::new(dir_context, working_dir);
934 (temp_dir, resolver)
935 }
936
937 #[test]
938 fn resolver_returns_defaults_when_no_config_files() {
939 let (_temp, resolver) = create_test_resolver();
940 let config = resolver.resolve().unwrap();
941
942 assert_eq!(config.editor.tab_size, 4);
944 assert!(config.editor.line_numbers);
945 }
946
947 #[test]
948 fn resolver_loads_user_layer() {
949 let (temp, resolver) = create_test_resolver();
950
951 let user_config_path = resolver.user_config_path();
953 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
954 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
955
956 let config = resolver.resolve().unwrap();
957 assert_eq!(config.editor.tab_size, 2);
958 assert!(config.editor.line_numbers); drop(temp);
960 }
961
962 #[test]
963 fn resolver_project_overrides_user() {
964 let (temp, resolver) = create_test_resolver();
965
966 let user_config_path = resolver.user_config_path();
968 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
969 std::fs::write(
970 &user_config_path,
971 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
972 )
973 .unwrap();
974
975 let project_config_path = resolver.project_config_path();
977 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
978 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
979
980 let config = resolver.resolve().unwrap();
981 assert_eq!(config.editor.tab_size, 8); assert!(!config.editor.line_numbers); drop(temp);
984 }
985
986 #[test]
987 fn resolver_session_overrides_all() {
988 let (temp, resolver) = create_test_resolver();
989
990 let user_config_path = resolver.user_config_path();
992 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
993 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
994
995 let project_config_path = resolver.project_config_path();
997 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
998 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 4}}"#).unwrap();
999
1000 let session_config_path = resolver.session_config_path();
1002 std::fs::write(&session_config_path, r#"{"editor": {"tab_size": 16}}"#).unwrap();
1003
1004 let config = resolver.resolve().unwrap();
1005 assert_eq!(config.editor.tab_size, 16); drop(temp);
1007 }
1008
1009 #[test]
1010 fn layer_precedence_ordering() {
1011 assert!(ConfigLayer::Session.precedence() > ConfigLayer::Project.precedence());
1012 assert!(ConfigLayer::Project.precedence() > ConfigLayer::User.precedence());
1013 assert!(ConfigLayer::User.precedence() > ConfigLayer::System.precedence());
1014 }
1015
1016 #[test]
1017 fn save_to_system_layer_fails() {
1018 let (_temp, resolver) = create_test_resolver();
1019 let config = Config::default();
1020 let result = resolver.save_to_layer(&config, ConfigLayer::System);
1021 assert!(result.is_err());
1022 }
1023
1024 #[test]
1025 fn resolver_loads_legacy_project_config() {
1026 let (temp, resolver) = create_test_resolver();
1027
1028 let working_dir = temp.path().join("project");
1030 let legacy_path = working_dir.join("config.json");
1031 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1032
1033 let config = resolver.resolve().unwrap();
1034 assert_eq!(config.editor.tab_size, 3);
1035 drop(temp);
1036 }
1037
1038 #[test]
1039 fn resolver_prefers_new_config_over_legacy() {
1040 let (temp, resolver) = create_test_resolver();
1041
1042 let working_dir = temp.path().join("project");
1044
1045 let legacy_path = working_dir.join("config.json");
1047 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1048
1049 let new_path = working_dir.join(".fresh").join("config.json");
1051 std::fs::create_dir_all(new_path.parent().unwrap()).unwrap();
1052 std::fs::write(&new_path, r#"{"editor": {"tab_size": 5}}"#).unwrap();
1053
1054 let config = resolver.resolve().unwrap();
1055 assert_eq!(config.editor.tab_size, 5); drop(temp);
1057 }
1058
1059 #[test]
1060 fn load_with_layers_works() {
1061 let temp = TempDir::new().unwrap();
1062 let dir_context = DirectoryContext::for_testing(temp.path());
1063 let working_dir = temp.path().join("project");
1064 std::fs::create_dir_all(&working_dir).unwrap();
1065
1066 std::fs::create_dir_all(&dir_context.config_dir).unwrap();
1068 std::fs::write(dir_context.config_path(), r#"{"editor": {"tab_size": 2}}"#).unwrap();
1069
1070 let config = Config::load_with_layers(&dir_context, &working_dir);
1071 assert_eq!(config.editor.tab_size, 2);
1072 }
1073
1074 #[test]
1075 fn platform_config_overrides_user() {
1076 let (temp, resolver) = create_test_resolver();
1077
1078 let user_config_path = resolver.user_config_path();
1080 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1081 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1082
1083 if let Some(platform_path) = resolver.user_platform_config_path() {
1085 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1086
1087 let config = resolver.resolve().unwrap();
1088 assert_eq!(config.editor.tab_size, 6); }
1090 drop(temp);
1091 }
1092
1093 #[test]
1094 fn project_overrides_platform() {
1095 let (temp, resolver) = create_test_resolver();
1096
1097 let user_config_path = resolver.user_config_path();
1099 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1100 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1101
1102 if let Some(platform_path) = resolver.user_platform_config_path() {
1104 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1105 }
1106
1107 let project_config_path = resolver.project_config_path();
1109 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1110 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 10}}"#).unwrap();
1111
1112 let config = resolver.resolve().unwrap();
1113 assert_eq!(config.editor.tab_size, 10); drop(temp);
1115 }
1116
1117 #[test]
1118 fn migration_adds_version() {
1119 let input = serde_json::json!({
1120 "editor": {"tab_size": 2}
1121 });
1122
1123 let migrated = migrate_config(input).unwrap();
1124
1125 assert_eq!(migrated.get("version"), Some(&serde_json::json!(1)));
1126 }
1127
1128 #[test]
1129 fn migration_renames_camelcase_keys() {
1130 let input = serde_json::json!({
1131 "editor": {
1132 "tabSize": 8,
1133 "lineNumbers": false
1134 }
1135 });
1136
1137 let migrated = migrate_config(input).unwrap();
1138
1139 let editor = migrated.get("editor").unwrap();
1140 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(8)));
1141 assert_eq!(editor.get("line_numbers"), Some(&serde_json::json!(false)));
1142 assert!(editor.get("tabSize").is_none());
1143 assert!(editor.get("lineNumbers").is_none());
1144 }
1145
1146 #[test]
1147 fn migration_preserves_existing_snake_case() {
1148 let input = serde_json::json!({
1149 "version": 1,
1150 "editor": {"tab_size": 4}
1151 });
1152
1153 let migrated = migrate_config(input).unwrap();
1154
1155 let editor = migrated.get("editor").unwrap();
1156 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(4)));
1157 }
1158
1159 #[test]
1160 fn resolver_loads_legacy_camelcase_config() {
1161 let (temp, resolver) = create_test_resolver();
1162
1163 let user_config_path = resolver.user_config_path();
1165 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1166 std::fs::write(
1167 &user_config_path,
1168 r#"{"editor": {"tabSize": 3, "lineNumbers": false}}"#,
1169 )
1170 .unwrap();
1171
1172 let config = resolver.resolve().unwrap();
1173 assert_eq!(config.editor.tab_size, 3);
1174 assert!(!config.editor.line_numbers);
1175 drop(temp);
1176 }
1177
1178 #[test]
1179 fn save_and_load_session() {
1180 let (_temp, resolver) = create_test_resolver();
1181
1182 let mut session = SessionConfig::new();
1183 session.set_theme(crate::config::ThemeName::from("dark"));
1184 session.set_editor_option(|e| e.tab_size = Some(2));
1185
1186 resolver.save_session(&session).unwrap();
1188
1189 let loaded = resolver.load_session().unwrap();
1191 assert_eq!(loaded.theme, Some(crate::config::ThemeName::from("dark")));
1192 assert_eq!(loaded.editor.as_ref().unwrap().tab_size, Some(2));
1193 }
1194
1195 #[test]
1196 fn clear_session_removes_file() {
1197 let (_temp, resolver) = create_test_resolver();
1198
1199 let mut session = SessionConfig::new();
1200 session.set_theme(crate::config::ThemeName::from("dark"));
1201
1202 resolver.save_session(&session).unwrap();
1204 assert!(resolver.session_config_path().exists());
1205
1206 resolver.clear_session().unwrap();
1207 assert!(!resolver.session_config_path().exists());
1208 }
1209
1210 #[test]
1211 fn load_session_returns_empty_when_no_file() {
1212 let (_temp, resolver) = create_test_resolver();
1213
1214 let session = resolver.load_session().unwrap();
1215 assert!(session.is_empty());
1216 }
1217
1218 #[test]
1219 fn session_affects_resolved_config() {
1220 let (_temp, resolver) = create_test_resolver();
1221
1222 let mut session = SessionConfig::new();
1224 session.set_editor_option(|e| e.tab_size = Some(16));
1225 resolver.save_session(&session).unwrap();
1226
1227 let config = resolver.resolve().unwrap();
1229 assert_eq!(config.editor.tab_size, 16);
1230 }
1231
1232 #[test]
1233 fn save_to_layer_writes_minimal_delta() {
1234 let (temp, resolver) = create_test_resolver();
1235
1236 let user_config_path = resolver.user_config_path();
1238 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1239 std::fs::write(
1240 &user_config_path,
1241 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1242 )
1243 .unwrap();
1244
1245 let mut config = resolver.resolve().unwrap();
1247 assert_eq!(config.editor.tab_size, 2);
1248 assert!(!config.editor.line_numbers);
1249
1250 config.editor.tab_size = 8;
1252
1253 resolver
1255 .save_to_layer(&config, ConfigLayer::Project)
1256 .unwrap();
1257
1258 let project_config_path = resolver.project_config_write_path();
1260 let content = std::fs::read_to_string(&project_config_path).unwrap();
1261 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1262
1263 assert_eq!(
1265 json.get("editor").and_then(|e| e.get("tab_size")),
1266 Some(&serde_json::json!(8)),
1267 "Project config should contain tab_size override"
1268 );
1269
1270 assert!(
1272 json.get("editor")
1273 .and_then(|e| e.get("line_numbers"))
1274 .is_none(),
1275 "Project config should NOT contain line_numbers (it's inherited from user layer)"
1276 );
1277
1278 assert!(
1280 json.get("editor")
1281 .and_then(|e| e.get("scroll_offset"))
1282 .is_none(),
1283 "Project config should NOT contain scroll_offset (it's a system default)"
1284 );
1285
1286 drop(temp);
1287 }
1288
1289 #[test]
1295 #[ignore = "Known limitation: save_to_layer cannot remove values that match parent layer"]
1296 fn save_to_layer_removes_inherited_values() {
1297 let (temp, resolver) = create_test_resolver();
1298
1299 let user_config_path = resolver.user_config_path();
1301 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1302 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1303
1304 let project_config_path = resolver.project_config_write_path();
1306 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1307 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1308
1309 let mut config = resolver.resolve().unwrap();
1311 assert_eq!(config.editor.tab_size, 8);
1312
1313 config.editor.tab_size = 2;
1315
1316 resolver
1318 .save_to_layer(&config, ConfigLayer::Project)
1319 .unwrap();
1320
1321 let content = std::fs::read_to_string(&project_config_path).unwrap();
1323 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1324
1325 assert!(
1327 json.get("editor").and_then(|e| e.get("tab_size")).is_none(),
1328 "Project config should NOT contain tab_size when it matches user layer"
1329 );
1330
1331 drop(temp);
1332 }
1333
1334 #[test]
1342 fn issue_630_save_to_file_strips_settings_matching_defaults() {
1343 let (_temp, resolver) = create_test_resolver();
1344
1345 let user_config_path = resolver.user_config_path();
1347 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1348 std::fs::write(
1349 &user_config_path,
1350 r#"{
1351 "theme": "dracula",
1352 "editor": {
1353 "tab_size": 2
1354 }
1355 }"#,
1356 )
1357 .unwrap();
1358
1359 let mut config = resolver.resolve().unwrap();
1361 assert_eq!(config.theme.0, "dracula");
1362 assert_eq!(config.editor.tab_size, 2);
1363
1364 if let Some(lsp_config) = config.lsp.get_mut("python") {
1366 lsp_config.enabled = false;
1367 }
1368
1369 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1371
1372 let content = std::fs::read_to_string(&user_config_path).unwrap();
1374 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1375
1376 eprintln!(
1377 "Saved config:\n{}",
1378 serde_json::to_string_pretty(&json).unwrap()
1379 );
1380
1381 assert_eq!(
1383 json.get("theme").and_then(|v| v.as_str()),
1384 Some("dracula"),
1385 "Theme should be saved (differs from default)"
1386 );
1387 assert_eq!(
1388 json.get("editor")
1389 .and_then(|e| e.get("tab_size"))
1390 .and_then(|v| v.as_u64()),
1391 Some(2),
1392 "tab_size should be saved (differs from default)"
1393 );
1394 assert_eq!(
1395 json.get("lsp")
1396 .and_then(|l| l.get("python"))
1397 .and_then(|p| p.get("enabled"))
1398 .and_then(|v| v.as_bool()),
1399 Some(false),
1400 "lsp.python.enabled should be saved (differs from default)"
1401 );
1402
1403 let reloaded = resolver.resolve().unwrap();
1405 assert_eq!(reloaded.theme.0, "dracula");
1406 assert_eq!(reloaded.editor.tab_size, 2);
1407 assert!(!reloaded.lsp["python"].enabled);
1408 assert_eq!(reloaded.lsp["python"].command, "pylsp");
1410 }
1411
1412 #[test]
1419 fn toggle_lsp_preserves_command() {
1420 let (_temp, resolver) = create_test_resolver();
1421 let user_config_path = resolver.user_config_path();
1422 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1423
1424 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1426
1427 let config = resolver.resolve().unwrap();
1429 let original_command = config.lsp["python"].command.clone();
1430 assert!(
1431 !original_command.is_empty(),
1432 "Default python LSP should have a command"
1433 );
1434
1435 let mut config = resolver.resolve().unwrap();
1437 config.lsp.get_mut("python").unwrap().enabled = false;
1438 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1439
1440 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1442 assert!(
1443 !saved_content.contains(r#""command""#),
1444 "Saved config should not contain 'command' field. File content: {}",
1445 saved_content
1446 );
1447 assert!(
1448 !saved_content.contains(r#""args""#),
1449 "Saved config should not contain 'args' field. File content: {}",
1450 saved_content
1451 );
1452
1453 let mut config = resolver.resolve().unwrap();
1455 assert!(!config.lsp["python"].enabled);
1456 config.lsp.get_mut("python").unwrap().enabled = true;
1457 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1458
1459 let config = resolver.resolve().unwrap();
1461 assert_eq!(
1462 config.lsp["python"].command, original_command,
1463 "Command should be preserved after toggling enabled. Got: '{}'",
1464 config.lsp["python"].command
1465 );
1466 }
1467
1468 #[test]
1479 fn issue_631_disabled_lsp_without_command_should_be_valid() {
1480 let (_temp, resolver) = create_test_resolver();
1481
1482 let user_config_path = resolver.user_config_path();
1484 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1485 std::fs::write(
1486 &user_config_path,
1487 r#"{
1488 "lsp": {
1489 "json": { "enabled": false },
1490 "python": { "enabled": false },
1491 "toml": { "enabled": false }
1492 },
1493 "theme": "dracula"
1494 }"#,
1495 )
1496 .unwrap();
1497
1498 let result = resolver.resolve();
1500
1501 assert!(
1504 result.is_ok(),
1505 "BUG #631: Config with disabled LSP should be valid even without 'command' field. \
1506 Got parse error: {:?}",
1507 result.err()
1508 );
1509
1510 let config = result.unwrap();
1512 assert_eq!(
1513 config.theme.0, "dracula",
1514 "Theme should be 'dracula' from config file"
1515 );
1516 }
1517
1518 #[test]
1520 fn loading_lsp_without_command_uses_default() {
1521 let (_temp, resolver) = create_test_resolver();
1522 let user_config_path = resolver.user_config_path();
1523 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1524
1525 std::fs::write(
1527 &user_config_path,
1528 r#"{ "lsp": { "rust": { "enabled": false } } }"#,
1529 )
1530 .unwrap();
1531
1532 let config = resolver.resolve().unwrap();
1534 assert_eq!(
1535 config.lsp["rust"].command, "rust-analyzer",
1536 "Command should come from defaults when not in file. Got: '{}'",
1537 config.lsp["rust"].command
1538 );
1539 assert!(
1540 !config.lsp["rust"].enabled,
1541 "enabled should be false from file"
1542 );
1543 }
1544
1545 #[test]
1551 fn settings_ui_toggle_lsp_preserves_command() {
1552 let (_temp, resolver) = create_test_resolver();
1553 let user_config_path = resolver.user_config_path();
1554 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1555
1556 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1558
1559 let config = resolver.resolve().unwrap();
1561 assert_eq!(
1562 config.lsp["rust"].command, "rust-analyzer",
1563 "Default rust command should be rust-analyzer"
1564 );
1565 assert!(
1566 config.lsp["rust"].enabled,
1567 "Default rust enabled should be true"
1568 );
1569
1570 let mut changes = std::collections::HashMap::new();
1573 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(false));
1574 let deletions = std::collections::HashSet::new();
1575
1576 resolver
1578 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1579 .unwrap();
1580
1581 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1583 eprintln!("After disable, file contains:\n{}", saved_content);
1584
1585 let reloaded = resolver.resolve().unwrap();
1587 assert_eq!(
1588 reloaded.lsp["rust"].command, "rust-analyzer",
1589 "Command should be preserved after save/reload (disabled). Got: '{}'",
1590 reloaded.lsp["rust"].command
1591 );
1592 assert!(!reloaded.lsp["rust"].enabled, "rust should be disabled");
1593
1594 let mut changes = std::collections::HashMap::new();
1596 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(true));
1597 let deletions = std::collections::HashSet::new();
1598
1599 resolver
1601 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1602 .unwrap();
1603
1604 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1606 eprintln!("After re-enable, file contains:\n{}", saved_content);
1607
1608 let final_config = resolver.resolve().unwrap();
1610 assert_eq!(
1611 final_config.lsp["rust"].command, "rust-analyzer",
1612 "Command should be preserved after toggle cycle. Got: '{}'",
1613 final_config.lsp["rust"].command
1614 );
1615 assert!(final_config.lsp["rust"].enabled, "rust should be enabled");
1616 }
1617
1618 #[test]
1629 fn issue_806_manual_config_edits_lost_when_saving_from_ui() {
1630 let (_temp, resolver) = create_test_resolver();
1631 let user_config_path = resolver.user_config_path();
1632 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1633
1634 std::fs::write(
1637 &user_config_path,
1638 r#"{
1639 "lsp": {
1640 "rust-analyzer": {
1641 "enabled": true,
1642 "command": "rust-analyzer",
1643 "args": ["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1644 "languages": ["rust"]
1645 }
1646 }
1647 }"#,
1648 )
1649 .unwrap();
1650
1651 let config = resolver.resolve().unwrap();
1653
1654 assert!(
1656 config.lsp.contains_key("rust-analyzer"),
1657 "Config should contain manually-added 'rust-analyzer' LSP entry"
1658 );
1659 let rust_analyzer = &config.lsp["rust-analyzer"];
1660 assert!(rust_analyzer.enabled, "rust-analyzer should be enabled");
1661 assert_eq!(
1662 rust_analyzer.command, "rust-analyzer",
1663 "rust-analyzer command should be preserved"
1664 );
1665 assert_eq!(
1666 rust_analyzer.args,
1667 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1668 "rust-analyzer args should be preserved"
1669 );
1670
1671 let mut config_json = serde_json::to_value(&config).unwrap();
1674 *config_json
1675 .pointer_mut("/editor/tab_size")
1676 .expect("path should exist") = serde_json::json!(2);
1677 let modified_config: crate::config::Config =
1678 serde_json::from_value(config_json).expect("should deserialize");
1679
1680 resolver
1682 .save_to_layer(&modified_config, ConfigLayer::User)
1683 .unwrap();
1684
1685 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1687 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1688
1689 eprintln!(
1690 "Issue #806 - Saved config after changing tab_size:\n{}",
1691 serde_json::to_string_pretty(&saved_json).unwrap()
1692 );
1693
1694 assert!(
1696 saved_json.get("lsp").is_some(),
1697 "BUG #806: 'lsp' section should NOT be deleted when saving unrelated changes. \
1698 File content: {}",
1699 saved_content
1700 );
1701
1702 assert!(
1703 saved_json
1704 .get("lsp")
1705 .and_then(|l| l.get("rust-analyzer"))
1706 .is_some(),
1707 "BUG #806: 'lsp.rust-analyzer' should NOT be deleted when saving unrelated changes. \
1708 File content: {}",
1709 saved_content
1710 );
1711
1712 let saved_args = saved_json
1714 .get("lsp")
1715 .and_then(|l| l.get("rust-analyzer"))
1716 .and_then(|r| r.get("args"));
1717 assert!(
1718 saved_args.is_some(),
1719 "BUG #806: 'lsp.rust-analyzer.args' should be preserved. File content: {}",
1720 saved_content
1721 );
1722 assert_eq!(
1723 saved_args.unwrap(),
1724 &serde_json::json!(["--log-file", "/tmp/rust-analyzer-{pid}.log"]),
1725 "BUG #806: Custom args should be preserved exactly"
1726 );
1727
1728 assert_eq!(
1730 saved_json
1731 .get("editor")
1732 .and_then(|e| e.get("tab_size"))
1733 .and_then(|v| v.as_u64()),
1734 Some(2),
1735 "tab_size should be saved"
1736 );
1737
1738 let reloaded = resolver.resolve().unwrap();
1740 assert_eq!(
1741 reloaded.editor.tab_size, 2,
1742 "tab_size change should be persisted"
1743 );
1744 assert!(
1745 reloaded.lsp.contains_key("rust-analyzer"),
1746 "BUG #806: rust-analyzer should still exist after reload"
1747 );
1748 let reloaded_ra = &reloaded.lsp["rust-analyzer"];
1749 assert_eq!(
1750 reloaded_ra.args,
1751 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1752 "BUG #806: Custom args should survive save/reload cycle"
1753 );
1754 }
1755
1756 #[test]
1761 fn issue_806_custom_lsp_entries_preserved_across_unrelated_changes() {
1762 let (_temp, resolver) = create_test_resolver();
1763 let user_config_path = resolver.user_config_path();
1764 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1765
1766 std::fs::write(
1768 &user_config_path,
1769 r#"{
1770 "theme": "dracula",
1771 "lsp": {
1772 "my-custom-lsp": {
1773 "enabled": true,
1774 "command": "/usr/local/bin/my-custom-lsp",
1775 "args": ["--verbose", "--config", "/etc/my-lsp.json"],
1776 "languages": ["mycustomlang"]
1777 }
1778 },
1779 "languages": {
1780 "mycustomlang": {
1781 "extensions": [".mcl"],
1782 "grammar": "mycustomlang"
1783 }
1784 }
1785 }"#,
1786 )
1787 .unwrap();
1788
1789 let config = resolver.resolve().unwrap();
1791 assert!(
1792 config.lsp.contains_key("my-custom-lsp"),
1793 "Custom LSP entry should be loaded"
1794 );
1795 assert!(
1796 config.languages.contains_key("mycustomlang"),
1797 "Custom language should be loaded"
1798 );
1799
1800 let mut config_json = serde_json::to_value(&config).unwrap();
1802 *config_json
1803 .pointer_mut("/editor/line_numbers")
1804 .expect("path should exist") = serde_json::json!(false);
1805 let modified_config: crate::config::Config =
1806 serde_json::from_value(config_json).expect("should deserialize");
1807
1808 resolver
1810 .save_to_layer(&modified_config, ConfigLayer::User)
1811 .unwrap();
1812
1813 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1815 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1816
1817 eprintln!(
1818 "Saved config:\n{}",
1819 serde_json::to_string_pretty(&saved_json).unwrap()
1820 );
1821
1822 assert!(
1824 saved_json
1825 .get("lsp")
1826 .and_then(|l| l.get("my-custom-lsp"))
1827 .is_some(),
1828 "BUG #806: Custom LSP 'my-custom-lsp' should be preserved. Got: {}",
1829 saved_content
1830 );
1831
1832 assert!(
1834 saved_json
1835 .get("languages")
1836 .and_then(|l| l.get("mycustomlang"))
1837 .is_some(),
1838 "BUG #806: Custom language 'mycustomlang' should be preserved. Got: {}",
1839 saved_content
1840 );
1841
1842 let reloaded = resolver.resolve().unwrap();
1844 assert!(
1845 reloaded.lsp.contains_key("my-custom-lsp"),
1846 "Custom LSP should survive save/reload"
1847 );
1848 assert!(
1849 reloaded.languages.contains_key("mycustomlang"),
1850 "Custom language should survive save/reload"
1851 );
1852 assert!(
1853 !reloaded.editor.line_numbers,
1854 "line_numbers change should be applied"
1855 );
1856 }
1857
1858 #[test]
1871 fn issue_806_external_file_modification_lost_on_ui_save() {
1872 let (_temp, resolver) = create_test_resolver();
1873 let user_config_path = resolver.user_config_path();
1874 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1875
1876 std::fs::write(&user_config_path, r#"{"theme": "monokai"}"#).unwrap();
1878
1879 let config_at_startup = resolver.resolve().unwrap();
1881 assert_eq!(config_at_startup.theme.0, "monokai");
1882 assert!(
1883 !config_at_startup.lsp.contains_key("rust-analyzer"),
1884 "No custom LSP at startup"
1885 );
1886
1887 std::fs::write(
1890 &user_config_path,
1891 r#"{
1892 "theme": "monokai",
1893 "lsp": {
1894 "rust-analyzer": {
1895 "enabled": true,
1896 "command": "rust-analyzer",
1897 "args": ["--log-file", "/tmp/ra.log"]
1898 }
1899 }
1900 }"#,
1901 )
1902 .unwrap();
1903
1904 let mut config_json = serde_json::to_value(&config_at_startup).unwrap();
1908 *config_json
1909 .pointer_mut("/editor/tab_size")
1910 .expect("path should exist") = serde_json::json!(2);
1911 let modified_config: crate::config::Config =
1912 serde_json::from_value(config_json).expect("should deserialize");
1913
1914 resolver
1918 .save_to_layer(&modified_config, ConfigLayer::User)
1919 .unwrap();
1920
1921 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1923 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1924
1925 eprintln!(
1926 "Issue #806 scenario 2 - After UI save (external edits should be preserved):\n{}",
1927 serde_json::to_string_pretty(&saved_json).unwrap()
1928 );
1929
1930 assert!(
1936 saved_json.get("lsp").is_some(),
1937 "BUG #806: External edits to config.json were lost! \
1938 The 'lsp' section added while Fresh was running should be preserved. \
1939 Saved content: {}",
1940 saved_content
1941 );
1942
1943 assert!(
1944 saved_json
1945 .get("lsp")
1946 .and_then(|l| l.get("rust-analyzer"))
1947 .is_some(),
1948 "BUG #806: rust-analyzer config should be preserved"
1949 );
1950 }
1951
1952 #[test]
1958 fn issue_806_concurrent_modification_scenario() {
1959 let (_temp, resolver) = create_test_resolver();
1960 let user_config_path = resolver.user_config_path();
1961 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1962
1963 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1965
1966 let mut config = resolver.resolve().unwrap();
1968
1969 config.editor.tab_size = 8;
1971
1972 std::fs::write(
1974 &user_config_path,
1975 r#"{
1976 "lsp": {
1977 "custom-lsp": {
1978 "enabled": true,
1979 "command": "/usr/bin/custom-lsp"
1980 }
1981 }
1982 }"#,
1983 )
1984 .unwrap();
1985
1986 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1989
1990 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1992 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1993
1994 eprintln!(
1995 "Concurrent modification scenario result:\n{}",
1996 serde_json::to_string_pretty(&saved_json).unwrap()
1997 );
1998
1999 assert_eq!(
2001 saved_json
2002 .get("editor")
2003 .and_then(|e| e.get("tab_size"))
2004 .and_then(|v| v.as_u64()),
2005 Some(8),
2006 "Our tab_size change should be saved"
2007 );
2008
2009 let lsp_preserved = saved_json.get("lsp").is_some();
2015 if !lsp_preserved {
2016 eprintln!(
2017 "NOTE: Concurrent file modifications are lost with current implementation. \
2018 This is expected behavior but could be improved with read-modify-write pattern."
2019 );
2020 }
2021 }
2022}