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
134fn find_changed_paths(old: &Value, new: &Value) -> std::collections::HashSet<String> {
137 let mut changed = std::collections::HashSet::new();
138 find_changed_paths_recursive(old, new, String::new(), &mut changed);
139 changed
140}
141
142fn find_changed_paths_recursive(
143 old: &Value,
144 new: &Value,
145 prefix: String,
146 changed: &mut std::collections::HashSet<String>,
147) {
148 match (old, new) {
149 (Value::Object(old_map), Value::Object(new_map)) => {
150 let all_keys: std::collections::HashSet<_> =
152 old_map.keys().chain(new_map.keys()).collect();
153 for key in all_keys {
154 let path = if prefix.is_empty() {
155 format!("/{}", key)
156 } else {
157 format!("{}/{}", prefix, key)
158 };
159 let old_val = old_map.get(key).unwrap_or(&Value::Null);
160 let new_val = new_map.get(key).unwrap_or(&Value::Null);
161 find_changed_paths_recursive(old_val, new_val, path, changed);
162 }
163 }
164 (old_val, new_val) if old_val != new_val => {
165 if !prefix.is_empty() {
167 changed.insert(prefix);
168 }
169 }
170 _ => {} }
172}
173
174fn write_clean_value_to_path(path: &Path, value: Value) -> Result<(), ConfigError> {
177 if let Some(parent_dir) = path.parent() {
178 std::fs::create_dir_all(parent_dir)
179 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
180 }
181 let stripped = strip_nulls(value).unwrap_or(Value::Object(Default::default()));
182 let clean = strip_empty_defaults(stripped).unwrap_or(Value::Object(Default::default()));
183 let json = serde_json::to_string_pretty(&clean)
184 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
185 std::fs::write(path, json)
186 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
187 Ok(())
188}
189
190fn read_existing_json(path: &Path) -> Result<Value, ConfigError> {
193 if !path.exists() {
194 return Ok(Value::Object(Default::default()));
195 }
196 let content = std::fs::read_to_string(path)
197 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
198 Ok(serde_json::from_str(&content).unwrap_or(Value::Object(Default::default())))
199}
200
201pub const CURRENT_CONFIG_VERSION: u32 = 2;
208
209pub fn migrate_config(mut value: Value) -> Result<Value, ConfigError> {
211 let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
212
213 if version < 1 {
215 value = migrate_v0_to_v1(value)?;
216 }
217 if version < 2 {
218 value = migrate_v1_to_v2(value)?;
219 }
220
221 Ok(value)
222}
223
224fn migrate_v0_to_v1(mut value: Value) -> Result<Value, ConfigError> {
227 if let Value::Object(ref mut map) = value {
228 map.insert("version".to_string(), Value::Number(1.into()));
230
231 if let Some(Value::Object(ref mut editor_map)) = map.get_mut("editor") {
233 if let Some(val) = editor_map.remove("tabSize") {
235 editor_map.entry("tab_size").or_insert(val);
236 }
237 if let Some(val) = editor_map.remove("lineNumbers") {
239 editor_map.entry("line_numbers").or_insert(val);
240 }
241 }
242 }
243 Ok(value)
244}
245
246fn migrate_v1_to_v2(mut value: Value) -> Result<Value, ConfigError> {
255 if let Value::Object(ref mut map) = value {
256 map.insert("version".to_string(), Value::Number(2.into()));
257
258 let left = map
259 .get_mut("editor")
260 .and_then(|editor| editor.as_object_mut())
261 .and_then(|editor| editor.get_mut("status_bar"))
262 .and_then(|status_bar| status_bar.as_object_mut())
263 .and_then(|status_bar| status_bar.get_mut("left"))
264 .and_then(|left| left.as_array_mut());
265
266 if let Some(left) = left {
267 let already_present = left.iter().any(|v| v.as_str() == Some("{remote}"));
268 if !already_present {
269 left.insert(0, Value::String("{remote}".to_string()));
270 }
271 }
272 }
273 Ok(value)
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum ConfigLayer {
279 System,
281 User,
283 Project,
285 Session,
287}
288
289impl ConfigLayer {
290 pub fn precedence(self) -> u8 {
292 match self {
293 Self::System => 0,
294 Self::User => 1,
295 Self::Project => 2,
296 Self::Session => 3,
297 }
298 }
299}
300
301pub struct ConfigResolver {
306 dir_context: DirectoryContext,
307 working_dir: PathBuf,
308}
309
310impl ConfigResolver {
311 pub fn new(dir_context: DirectoryContext, working_dir: PathBuf) -> Self {
313 Self {
314 dir_context,
315 working_dir,
316 }
317 }
318
319 pub fn resolve(&self) -> Result<Config, ConfigError> {
326 let mut merged = self.load_session_layer()?.unwrap_or_default();
328
329 if let Some(project_partial) = self.load_project_layer()? {
331 tracing::debug!("Loaded project config layer");
332 merged.merge_from(&project_partial);
333 }
334
335 if let Some(platform_partial) = self.load_user_platform_layer()? {
337 tracing::debug!("Loaded user platform config layer");
338 merged.merge_from(&platform_partial);
339 }
340
341 if let Some(user_partial) = self.load_user_layer()? {
343 tracing::debug!("Loaded user config layer");
344 merged.merge_from(&user_partial);
345 }
346
347 Ok(merged.resolve())
349 }
350
351 pub fn user_config_path(&self) -> PathBuf {
353 self.dir_context.config_path()
354 }
355
356 pub fn project_config_path(&self) -> PathBuf {
359 let new_path = self.working_dir.join(".fresh").join("config.json");
360 if new_path.exists() {
361 return new_path;
362 }
363 let legacy_path = self.working_dir.join("config.json");
365 if legacy_path.exists() {
366 return legacy_path;
367 }
368 new_path
370 }
371
372 pub fn project_config_write_path(&self) -> PathBuf {
374 self.working_dir.join(".fresh").join("config.json")
375 }
376
377 pub fn session_config_path(&self) -> PathBuf {
379 self.working_dir.join(".fresh").join("session.json")
380 }
381
382 fn platform_config_filename() -> Option<&'static str> {
384 if cfg!(target_os = "linux") {
385 Some("config_linux.json")
386 } else if cfg!(target_os = "macos") {
387 Some("config_macos.json")
388 } else if cfg!(target_os = "windows") {
389 Some("config_windows.json")
390 } else {
391 None
392 }
393 }
394
395 pub fn user_platform_config_path(&self) -> Option<PathBuf> {
397 Self::platform_config_filename().map(|filename| self.dir_context.config_dir.join(filename))
398 }
399
400 pub fn load_user_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
402 self.load_layer_from_path(&self.user_config_path())
403 }
404
405 pub fn load_user_platform_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
407 if let Some(path) = self.user_platform_config_path() {
408 self.load_layer_from_path(&path)
409 } else {
410 Ok(None)
411 }
412 }
413
414 pub fn load_project_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
416 self.load_layer_from_path(&self.project_config_path())
417 }
418
419 pub fn load_session_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
421 self.load_layer_from_path(&self.session_config_path())
422 }
423
424 fn load_layer_from_path(&self, path: &Path) -> Result<Option<PartialConfig>, ConfigError> {
426 if !path.exists() {
427 return Ok(None);
428 }
429
430 let content = std::fs::read_to_string(path)
431 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
432
433 let value: Value = serde_json::from_str(&content)
435 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
436
437 let migrated = migrate_config(value)?;
439
440 let partial: PartialConfig = serde_json::from_value(migrated)
442 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
443
444 Ok(Some(partial))
445 }
446
447 fn layer_write_path(&self, layer: ConfigLayer) -> Result<PathBuf, ConfigError> {
450 match layer {
451 ConfigLayer::User => Ok(self.user_config_path()),
452 ConfigLayer::Project => Ok(self.project_config_write_path()),
453 ConfigLayer::Session => Ok(self.session_config_path()),
454 ConfigLayer::System => Err(ConfigError::ValidationError(
455 "Cannot write to System layer".to_string(),
456 )),
457 }
458 }
459
460 pub fn save_to_layer(&self, config: &Config, layer: ConfigLayer) -> Result<(), ConfigError> {
462 let path = self.layer_write_path(layer)?;
463
464 let parent_partial = self.resolve_up_to_layer(layer)?;
465 let parent = PartialConfig::from(&parent_partial.resolve());
466 let current = PartialConfig::from(config);
467 let delta = diff_partial_config(¤t, &parent);
468
469 let existing: PartialConfig = if path.exists() {
471 let content = std::fs::read_to_string(&path)
472 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
473 serde_json::from_str(&content).unwrap_or_default()
474 } else {
475 PartialConfig::default()
476 };
477 let mut merged = delta;
478 merged.merge_from(&existing);
479
480 let merged_value = serde_json::to_value(&merged)
481 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
482 write_clean_value_to_path(&path, merged_value)
483 }
484
485 pub fn save_to_layer_with_baseline(
495 &self,
496 current: &Config,
497 baseline: &Config,
498 layer: ConfigLayer,
499 ) -> Result<(), ConfigError> {
500 let path = self.layer_write_path(layer)?;
501
502 let parent_partial = self.resolve_up_to_layer(layer)?;
503 let parent = PartialConfig::from(&parent_partial.resolve());
504
505 let current_json = serde_json::to_value(current)
506 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
507 let baseline_json = serde_json::to_value(baseline)
508 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
509 let parent_json = serde_json::to_value(&parent)
510 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
511
512 let changed_paths = find_changed_paths(&baseline_json, ¤t_json);
513
514 let mut result = read_existing_json(&path)?;
515
516 for pointer in &changed_paths {
518 let current_val = current_json.pointer(pointer);
519 let parent_val = parent_json.pointer(pointer);
520 if current_val == parent_val {
521 remove_json_pointer(&mut result, pointer);
522 } else if let Some(val) = current_val {
523 set_json_pointer(&mut result, pointer, val.clone());
524 }
525 }
526
527 write_clean_value_to_path(&path, result)
528 }
529
530 pub fn save_changes_to_layer(
535 &self,
536 changes: &std::collections::HashMap<String, serde_json::Value>,
537 deletions: &std::collections::HashSet<String>,
538 layer: ConfigLayer,
539 ) -> Result<(), ConfigError> {
540 let path = self.layer_write_path(layer)?;
541
542 let mut config_value = read_existing_json(&path)?;
543
544 for pointer in deletions {
545 remove_json_pointer(&mut config_value, pointer);
546 }
547 for (pointer, value) in changes {
548 set_json_pointer(&mut config_value, pointer, value.clone());
549 }
550
551 let _: PartialConfig = serde_json::from_value(config_value.clone()).map_err(|e| {
553 ConfigError::ValidationError(format!("Result config would be invalid: {}", e))
554 })?;
555
556 write_clean_value_to_path(&path, config_value)
557 }
558
559 pub fn save_session(&self, session: &SessionConfig) -> Result<(), ConfigError> {
561 let path = self.session_config_path();
562
563 if let Some(parent_dir) = path.parent() {
565 std::fs::create_dir_all(parent_dir)
566 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
567 }
568
569 let json = serde_json::to_string_pretty(session)
570 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
571 std::fs::write(&path, json)
572 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
573
574 tracing::debug!("Saved session config to {}", path.display());
575 Ok(())
576 }
577
578 pub fn load_session(&self) -> Result<SessionConfig, ConfigError> {
580 match self.load_session_layer()? {
581 Some(partial) => Ok(SessionConfig::from(partial)),
582 None => Ok(SessionConfig::new()),
583 }
584 }
585
586 pub fn clear_session(&self) -> Result<(), ConfigError> {
588 let path = self.session_config_path();
589 if path.exists() {
590 std::fs::remove_file(&path)
591 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
592 tracing::debug!("Cleared session config at {}", path.display());
593 }
594 Ok(())
595 }
596
597 fn resolve_up_to_layer(&self, layer: ConfigLayer) -> Result<PartialConfig, ConfigError> {
600 let mut merged = PartialConfig::default();
601
602 if layer == ConfigLayer::Session {
608 if let Some(project) = self.load_project_layer()? {
610 merged = project;
611 }
612 if let Some(platform) = self.load_user_platform_layer()? {
613 merged.merge_from(&platform);
614 }
615 if let Some(user) = self.load_user_layer()? {
616 merged.merge_from(&user);
617 }
618 } else if layer == ConfigLayer::Project {
619 if let Some(platform) = self.load_user_platform_layer()? {
621 merged = platform;
622 }
623 if let Some(user) = self.load_user_layer()? {
624 merged.merge_from(&user);
625 }
626 }
627 Ok(merged)
630 }
631
632 pub fn get_layer_sources(
635 &self,
636 ) -> Result<std::collections::HashMap<String, ConfigLayer>, ConfigError> {
637 use std::collections::HashMap;
638
639 let mut sources: HashMap<String, ConfigLayer> = HashMap::new();
640
641 if let Some(session) = self.load_session_layer()? {
646 let json = serde_json::to_value(&session).unwrap_or_default();
647 collect_paths(&json, "", &mut |path| {
648 sources.insert(path, ConfigLayer::Session);
649 });
650 }
651
652 if let Some(project) = self.load_project_layer()? {
653 let json = serde_json::to_value(&project).unwrap_or_default();
654 collect_paths(&json, "", &mut |path| {
655 sources.entry(path).or_insert(ConfigLayer::Project);
656 });
657 }
658
659 if let Some(user) = self.load_user_layer()? {
660 let json = serde_json::to_value(&user).unwrap_or_default();
661 collect_paths(&json, "", &mut |path| {
662 sources.entry(path).or_insert(ConfigLayer::User);
663 });
664 }
665
666 Ok(sources)
669 }
670}
671
672fn collect_paths<F>(value: &Value, prefix: &str, collector: &mut F)
674where
675 F: FnMut(String),
676{
677 match value {
678 Value::Object(map) => {
679 for (key, val) in map {
680 let path = if prefix.is_empty() {
681 format!("/{}", key)
682 } else {
683 format!("{}/{}", prefix, key)
684 };
685 collect_paths(val, &path, collector);
686 }
687 }
688 Value::Null => {} _ => {
690 collector(prefix.to_string());
692 }
693 }
694}
695
696fn diff_partial_config(current: &PartialConfig, parent: &PartialConfig) -> PartialConfig {
699 let current_json = serde_json::to_value(current).unwrap_or_default();
701 let parent_json = serde_json::to_value(parent).unwrap_or_default();
702
703 let diff = json_diff(&parent_json, ¤t_json);
704
705 serde_json::from_value(diff).unwrap_or_default()
707}
708
709impl Config {
710 fn system_config_paths() -> Vec<PathBuf> {
715 let mut paths = Vec::with_capacity(2);
716
717 #[cfg(target_os = "macos")]
719 if let Some(home) = dirs::home_dir() {
720 let path = home.join(".config").join("fresh").join(Config::FILENAME);
721 if path.exists() {
722 paths.push(path);
723 }
724 }
725
726 if let Some(config_dir) = dirs::config_dir() {
728 let path = config_dir.join("fresh").join(Config::FILENAME);
729 if !paths.contains(&path) && path.exists() {
730 paths.push(path);
731 }
732 }
733
734 paths
735 }
736
737 fn config_search_paths(working_dir: &Path) -> Vec<PathBuf> {
745 let local = Self::local_config_path(working_dir);
746 let mut paths = Vec::with_capacity(3);
747
748 if local.exists() {
749 paths.push(local);
750 }
751
752 paths.extend(Self::system_config_paths());
753 paths
754 }
755
756 pub fn find_config_path(working_dir: &Path) -> Option<PathBuf> {
760 Self::config_search_paths(working_dir).into_iter().next()
761 }
762
763 pub fn load_with_layers(dir_context: &DirectoryContext, working_dir: &Path) -> Self {
768 let resolver = ConfigResolver::new(dir_context.clone(), working_dir.to_path_buf());
769 match resolver.resolve() {
770 Ok(config) => {
771 tracing::info!("Loaded layered config for {}", working_dir.display());
772 config
773 }
774 Err(e) => {
775 tracing::warn!("Failed to load layered config: {}, using defaults", e);
776 Self::default()
777 }
778 }
779 }
780
781 pub fn read_user_config_raw(working_dir: &Path) -> serde_json::Value {
789 for path in Self::config_search_paths(working_dir) {
790 if let Ok(contents) = std::fs::read_to_string(&path) {
791 match serde_json::from_str(&contents) {
792 Ok(value) => return value,
793 Err(e) => {
794 tracing::warn!("Failed to parse config from {}: {}", path.display(), e);
795 }
796 }
797 }
798 }
799 serde_json::Value::Object(serde_json::Map::new())
800 }
801}
802
803fn json_diff(defaults: &serde_json::Value, current: &serde_json::Value) -> serde_json::Value {
806 use serde_json::Value;
807
808 match (defaults, current) {
809 (Value::Object(def_map), Value::Object(cur_map)) => {
811 let mut result = serde_json::Map::new();
812
813 for (key, cur_val) in cur_map {
814 if let Some(def_val) = def_map.get(key) {
815 let diff = json_diff(def_val, cur_val);
817 if !is_empty_diff(&diff) {
819 result.insert(key.clone(), diff);
820 }
821 } else {
822 if let Some(stripped) = strip_empty_defaults(cur_val.clone()) {
824 result.insert(key.clone(), stripped);
825 }
826 }
827 }
828
829 Value::Object(result)
830 }
831 _ => {
833 if let Value::String(s) = current {
835 if s.is_empty() {
836 return Value::Object(serde_json::Map::new()); }
838 }
839 if defaults == current {
840 Value::Object(serde_json::Map::new()) } else {
842 current.clone()
843 }
844 }
845 }
846}
847
848fn is_empty_diff(value: &serde_json::Value) -> bool {
850 match value {
851 serde_json::Value::Object(map) => map.is_empty(),
852 _ => false,
853 }
854}
855
856#[derive(Debug, Clone)]
867pub struct DirectoryContext {
868 pub data_dir: std::path::PathBuf,
871
872 pub config_dir: std::path::PathBuf,
875
876 pub home_dir: Option<std::path::PathBuf>,
878
879 pub documents_dir: Option<std::path::PathBuf>,
881
882 pub downloads_dir: Option<std::path::PathBuf>,
884}
885
886impl DirectoryContext {
887 pub fn from_system() -> std::io::Result<Self> {
890 let data_dir = dirs::data_dir()
891 .ok_or_else(|| {
892 std::io::Error::new(
893 std::io::ErrorKind::NotFound,
894 "Could not determine data directory",
895 )
896 })?
897 .join("fresh");
898
899 let config_dir = Self::default_config_dir().ok_or_else(|| {
900 std::io::Error::new(
901 std::io::ErrorKind::NotFound,
902 "Could not determine config directory",
903 )
904 })?;
905
906 Ok(Self {
907 data_dir,
908 config_dir,
909 home_dir: dirs::home_dir(),
910 documents_dir: dirs::document_dir(),
911 downloads_dir: dirs::download_dir(),
912 })
913 }
914
915 pub fn for_testing(temp_dir: &std::path::Path) -> Self {
918 Self {
919 data_dir: temp_dir.join("data"),
920 config_dir: temp_dir.join("config"),
921 home_dir: Some(temp_dir.join("home")),
922 documents_dir: Some(temp_dir.join("documents")),
923 downloads_dir: Some(temp_dir.join("downloads")),
924 }
925 }
926
927 pub fn recovery_dir(&self) -> std::path::PathBuf {
929 self.data_dir.join("recovery")
930 }
931
932 pub fn workspaces_dir(&self) -> std::path::PathBuf {
934 self.data_dir.join("workspaces")
935 }
936
937 pub fn project_state_dir(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
953 let canonical = working_dir
954 .canonicalize()
955 .unwrap_or_else(|_| working_dir.to_path_buf());
956 self.workspaces_dir()
957 .join(crate::workspace::encode_path_for_filename(&canonical))
958 }
959
960 pub fn prompt_history_path(&self, history_name: &str) -> std::path::PathBuf {
964 let safe_name = history_name.replace(':', "_");
966 self.data_dir.join(format!("{}_history.json", safe_name))
967 }
968
969 pub fn search_history_path(&self) -> std::path::PathBuf {
971 self.prompt_history_path("search")
972 }
973
974 pub fn replace_history_path(&self) -> std::path::PathBuf {
976 self.prompt_history_path("replace")
977 }
978
979 pub fn goto_line_history_path(&self) -> std::path::PathBuf {
981 self.prompt_history_path("goto_line")
982 }
983
984 pub fn terminals_dir(&self) -> std::path::PathBuf {
986 self.data_dir.join("terminals")
987 }
988
989 pub fn terminal_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
991 let encoded = crate::workspace::encode_path_for_filename(working_dir);
992 self.terminals_dir().join(encoded)
993 }
994
995 pub fn config_path(&self) -> std::path::PathBuf {
997 self.config_dir.join(Config::FILENAME)
998 }
999
1000 pub fn themes_dir(&self) -> std::path::PathBuf {
1002 self.config_dir.join("themes")
1003 }
1004
1005 pub fn grammars_dir(&self) -> std::path::PathBuf {
1007 self.config_dir.join("grammars")
1008 }
1009
1010 pub fn plugins_dir(&self) -> std::path::PathBuf {
1012 self.config_dir.join("plugins")
1013 }
1014
1015 fn default_config_dir() -> Option<std::path::PathBuf> {
1022 #[cfg(target_os = "macos")]
1023 {
1024 dirs::home_dir().map(|p| p.join(".config").join("fresh"))
1025 }
1026
1027 #[cfg(not(target_os = "macos"))]
1028 {
1029 dirs::config_dir().map(|p| p.join("fresh"))
1030 }
1031 }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::*;
1037 use tempfile::TempDir;
1038
1039 fn create_test_resolver() -> (TempDir, ConfigResolver) {
1040 let temp_dir = TempDir::new().unwrap();
1041 let dir_context = DirectoryContext::for_testing(temp_dir.path());
1042 let working_dir = temp_dir.path().join("project");
1043 std::fs::create_dir_all(&working_dir).unwrap();
1044 let resolver = ConfigResolver::new(dir_context, working_dir);
1045 (temp_dir, resolver)
1046 }
1047
1048 #[test]
1049 fn resolver_returns_defaults_when_no_config_files() {
1050 let (_temp, resolver) = create_test_resolver();
1051 let config = resolver.resolve().unwrap();
1052
1053 assert_eq!(config.editor.tab_size, 4);
1055 assert!(config.editor.line_numbers);
1056 }
1057
1058 #[test]
1059 fn resolver_loads_user_layer() {
1060 let (temp, resolver) = create_test_resolver();
1061
1062 let user_config_path = resolver.user_config_path();
1064 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1065 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1066
1067 let config = resolver.resolve().unwrap();
1068 assert_eq!(config.editor.tab_size, 2);
1069 assert!(config.editor.line_numbers); drop(temp);
1071 }
1072
1073 #[test]
1074 fn resolver_project_overrides_user() {
1075 let (temp, resolver) = create_test_resolver();
1076
1077 let user_config_path = resolver.user_config_path();
1079 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1080 std::fs::write(
1081 &user_config_path,
1082 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1083 )
1084 .unwrap();
1085
1086 let project_config_path = resolver.project_config_path();
1088 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1089 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1090
1091 let config = resolver.resolve().unwrap();
1092 assert_eq!(config.editor.tab_size, 8); assert!(!config.editor.line_numbers); drop(temp);
1095 }
1096
1097 #[test]
1098 fn resolver_session_overrides_all() {
1099 let (temp, resolver) = create_test_resolver();
1100
1101 let user_config_path = resolver.user_config_path();
1103 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1104 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1105
1106 let project_config_path = resolver.project_config_path();
1108 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1109 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 4}}"#).unwrap();
1110
1111 let session_config_path = resolver.session_config_path();
1113 std::fs::write(&session_config_path, r#"{"editor": {"tab_size": 16}}"#).unwrap();
1114
1115 let config = resolver.resolve().unwrap();
1116 assert_eq!(config.editor.tab_size, 16); drop(temp);
1118 }
1119
1120 #[test]
1121 fn layer_precedence_ordering() {
1122 assert!(ConfigLayer::Session.precedence() > ConfigLayer::Project.precedence());
1123 assert!(ConfigLayer::Project.precedence() > ConfigLayer::User.precedence());
1124 assert!(ConfigLayer::User.precedence() > ConfigLayer::System.precedence());
1125 }
1126
1127 #[test]
1128 fn save_to_system_layer_fails() {
1129 let (_temp, resolver) = create_test_resolver();
1130 let config = Config::default();
1131 let result = resolver.save_to_layer(&config, ConfigLayer::System);
1132 assert!(result.is_err());
1133 }
1134
1135 #[test]
1136 fn resolver_loads_legacy_project_config() {
1137 let (temp, resolver) = create_test_resolver();
1138
1139 let working_dir = temp.path().join("project");
1141 let legacy_path = working_dir.join("config.json");
1142 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1143
1144 let config = resolver.resolve().unwrap();
1145 assert_eq!(config.editor.tab_size, 3);
1146 drop(temp);
1147 }
1148
1149 #[test]
1150 fn resolver_prefers_new_config_over_legacy() {
1151 let (temp, resolver) = create_test_resolver();
1152
1153 let working_dir = temp.path().join("project");
1155
1156 let legacy_path = working_dir.join("config.json");
1158 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1159
1160 let new_path = working_dir.join(".fresh").join("config.json");
1162 std::fs::create_dir_all(new_path.parent().unwrap()).unwrap();
1163 std::fs::write(&new_path, r#"{"editor": {"tab_size": 5}}"#).unwrap();
1164
1165 let config = resolver.resolve().unwrap();
1166 assert_eq!(config.editor.tab_size, 5); drop(temp);
1168 }
1169
1170 #[test]
1171 fn load_with_layers_works() {
1172 let temp = TempDir::new().unwrap();
1173 let dir_context = DirectoryContext::for_testing(temp.path());
1174 let working_dir = temp.path().join("project");
1175 std::fs::create_dir_all(&working_dir).unwrap();
1176
1177 std::fs::create_dir_all(&dir_context.config_dir).unwrap();
1179 std::fs::write(dir_context.config_path(), r#"{"editor": {"tab_size": 2}}"#).unwrap();
1180
1181 let config = Config::load_with_layers(&dir_context, &working_dir);
1182 assert_eq!(config.editor.tab_size, 2);
1183 }
1184
1185 #[test]
1186 fn platform_config_overrides_user() {
1187 let (temp, resolver) = create_test_resolver();
1188
1189 let user_config_path = resolver.user_config_path();
1191 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1192 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1193
1194 if let Some(platform_path) = resolver.user_platform_config_path() {
1196 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1197
1198 let config = resolver.resolve().unwrap();
1199 assert_eq!(config.editor.tab_size, 6); }
1201 drop(temp);
1202 }
1203
1204 #[test]
1205 fn project_overrides_platform() {
1206 let (temp, resolver) = create_test_resolver();
1207
1208 let user_config_path = resolver.user_config_path();
1210 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1211 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1212
1213 if let Some(platform_path) = resolver.user_platform_config_path() {
1215 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1216 }
1217
1218 let project_config_path = resolver.project_config_path();
1220 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1221 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 10}}"#).unwrap();
1222
1223 let config = resolver.resolve().unwrap();
1224 assert_eq!(config.editor.tab_size, 10); drop(temp);
1226 }
1227
1228 #[test]
1229 fn migration_adds_version() {
1230 let input = serde_json::json!({
1231 "editor": {"tab_size": 2}
1232 });
1233
1234 let migrated = migrate_config(input).unwrap();
1235
1236 assert_eq!(
1237 migrated.get("version"),
1238 Some(&serde_json::json!(CURRENT_CONFIG_VERSION))
1239 );
1240 }
1241
1242 #[test]
1243 fn migration_v1_to_v2_injects_remote_element() {
1244 let input = serde_json::json!({
1247 "version": 1,
1248 "editor": {
1249 "status_bar": {
1250 "left": ["{filename}", "{cursor}"]
1251 }
1252 }
1253 });
1254
1255 let migrated = migrate_config(input).unwrap();
1256
1257 assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
1258 let left = migrated
1259 .pointer("/editor/status_bar/left")
1260 .and_then(|v| v.as_array())
1261 .expect("status_bar.left should remain an array");
1262 assert_eq!(left[0], serde_json::json!("{remote}"));
1263 assert_eq!(left[1], serde_json::json!("{filename}"));
1264 assert_eq!(left[2], serde_json::json!("{cursor}"));
1265 }
1266
1267 #[test]
1268 fn migration_v1_to_v2_is_idempotent() {
1269 let input = serde_json::json!({
1272 "version": 1,
1273 "editor": {
1274 "status_bar": {
1275 "left": ["{filename}", "{remote}", "{cursor}"]
1276 }
1277 }
1278 });
1279
1280 let migrated = migrate_config(input).unwrap();
1281
1282 let left = migrated
1283 .pointer("/editor/status_bar/left")
1284 .and_then(|v| v.as_array())
1285 .unwrap();
1286 let remote_count = left
1287 .iter()
1288 .filter(|v| v.as_str() == Some("{remote}"))
1289 .count();
1290 assert_eq!(
1291 remote_count, 1,
1292 "migration should never duplicate an existing {{remote}} entry; left = {:?}",
1293 left
1294 );
1295 }
1296
1297 #[test]
1298 fn migration_v1_to_v2_leaves_default_users_alone() {
1299 let input = serde_json::json!({
1303 "version": 1,
1304 "editor": {"tab_size": 4}
1305 });
1306
1307 let migrated = migrate_config(input).unwrap();
1308
1309 assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
1310 assert!(
1311 migrated.pointer("/editor/status_bar").is_none(),
1312 "migration must not fabricate a status_bar object for users \
1313 who never overrode the default; migrated = {:?}",
1314 migrated
1315 );
1316 }
1317
1318 #[test]
1319 fn migration_renames_camelcase_keys() {
1320 let input = serde_json::json!({
1321 "editor": {
1322 "tabSize": 8,
1323 "lineNumbers": false
1324 }
1325 });
1326
1327 let migrated = migrate_config(input).unwrap();
1328
1329 let editor = migrated.get("editor").unwrap();
1330 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(8)));
1331 assert_eq!(editor.get("line_numbers"), Some(&serde_json::json!(false)));
1332 assert!(editor.get("tabSize").is_none());
1333 assert!(editor.get("lineNumbers").is_none());
1334 }
1335
1336 #[test]
1337 fn migration_preserves_existing_snake_case() {
1338 let input = serde_json::json!({
1339 "version": 1,
1340 "editor": {"tab_size": 4}
1341 });
1342
1343 let migrated = migrate_config(input).unwrap();
1344
1345 let editor = migrated.get("editor").unwrap();
1346 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(4)));
1347 }
1348
1349 #[test]
1350 fn resolver_loads_legacy_camelcase_config() {
1351 let (temp, resolver) = create_test_resolver();
1352
1353 let user_config_path = resolver.user_config_path();
1355 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1356 std::fs::write(
1357 &user_config_path,
1358 r#"{"editor": {"tabSize": 3, "lineNumbers": false}}"#,
1359 )
1360 .unwrap();
1361
1362 let config = resolver.resolve().unwrap();
1363 assert_eq!(config.editor.tab_size, 3);
1364 assert!(!config.editor.line_numbers);
1365 drop(temp);
1366 }
1367
1368 #[test]
1369 fn resolver_migrates_v1_status_bar_left_on_load() {
1370 let (temp, resolver) = create_test_resolver();
1375
1376 let user_config_path = resolver.user_config_path();
1377 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1378 std::fs::write(
1379 &user_config_path,
1380 r#"{
1381 "version": 1,
1382 "editor": {
1383 "status_bar": {
1384 "left": ["{filename}", "{cursor}"],
1385 "right": []
1386 }
1387 }
1388 }"#,
1389 )
1390 .unwrap();
1391
1392 let config = resolver.resolve().unwrap();
1393 let left = &config.editor.status_bar.left;
1394 assert_eq!(
1395 left.first().cloned(),
1396 Some(crate::config::StatusBarElement::RemoteIndicator),
1397 "resolver should inject RemoteIndicator at index 0 during v1→v2 \
1398 migration; left = {:?}",
1399 left
1400 );
1401 drop(temp);
1402 }
1403
1404 #[test]
1405 fn save_and_load_session() {
1406 let (_temp, resolver) = create_test_resolver();
1407
1408 let mut session = SessionConfig::new();
1409 session.set_theme(crate::config::ThemeName::from("dark"));
1410 session.set_editor_option(|e| e.tab_size = Some(2));
1411
1412 resolver.save_session(&session).unwrap();
1414
1415 let loaded = resolver.load_session().unwrap();
1417 assert_eq!(loaded.theme, Some(crate::config::ThemeName::from("dark")));
1418 assert_eq!(loaded.editor.as_ref().unwrap().tab_size, Some(2));
1419 }
1420
1421 #[test]
1422 fn clear_session_removes_file() {
1423 let (_temp, resolver) = create_test_resolver();
1424
1425 let mut session = SessionConfig::new();
1426 session.set_theme(crate::config::ThemeName::from("dark"));
1427
1428 resolver.save_session(&session).unwrap();
1430 assert!(resolver.session_config_path().exists());
1431
1432 resolver.clear_session().unwrap();
1433 assert!(!resolver.session_config_path().exists());
1434 }
1435
1436 #[test]
1437 fn load_session_returns_empty_when_no_file() {
1438 let (_temp, resolver) = create_test_resolver();
1439
1440 let session = resolver.load_session().unwrap();
1441 assert!(session.is_empty());
1442 }
1443
1444 #[test]
1445 fn session_affects_resolved_config() {
1446 let (_temp, resolver) = create_test_resolver();
1447
1448 let mut session = SessionConfig::new();
1450 session.set_editor_option(|e| e.tab_size = Some(16));
1451 resolver.save_session(&session).unwrap();
1452
1453 let config = resolver.resolve().unwrap();
1455 assert_eq!(config.editor.tab_size, 16);
1456 }
1457
1458 #[test]
1459 fn save_to_layer_writes_minimal_delta() {
1460 let (temp, resolver) = create_test_resolver();
1461
1462 let user_config_path = resolver.user_config_path();
1464 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1465 std::fs::write(
1466 &user_config_path,
1467 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1468 )
1469 .unwrap();
1470
1471 let mut config = resolver.resolve().unwrap();
1473 assert_eq!(config.editor.tab_size, 2);
1474 assert!(!config.editor.line_numbers);
1475
1476 config.editor.tab_size = 8;
1478
1479 resolver
1481 .save_to_layer(&config, ConfigLayer::Project)
1482 .unwrap();
1483
1484 let project_config_path = resolver.project_config_write_path();
1486 let content = std::fs::read_to_string(&project_config_path).unwrap();
1487 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1488
1489 assert_eq!(
1491 json.get("editor").and_then(|e| e.get("tab_size")),
1492 Some(&serde_json::json!(8)),
1493 "Project config should contain tab_size override"
1494 );
1495
1496 assert!(
1498 json.get("editor")
1499 .and_then(|e| e.get("line_numbers"))
1500 .is_none(),
1501 "Project config should NOT contain line_numbers (it's inherited from user layer)"
1502 );
1503
1504 assert!(
1506 json.get("editor")
1507 .and_then(|e| e.get("scroll_offset"))
1508 .is_none(),
1509 "Project config should NOT contain scroll_offset (it's a system default)"
1510 );
1511
1512 drop(temp);
1513 }
1514
1515 #[test]
1521 #[ignore = "Known limitation: save_to_layer cannot remove values that match parent layer"]
1522 fn save_to_layer_removes_inherited_values() {
1523 let (temp, resolver) = create_test_resolver();
1524
1525 let user_config_path = resolver.user_config_path();
1527 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1528 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1529
1530 let project_config_path = resolver.project_config_write_path();
1532 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1533 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1534
1535 let mut config = resolver.resolve().unwrap();
1537 assert_eq!(config.editor.tab_size, 8);
1538
1539 config.editor.tab_size = 2;
1541
1542 resolver
1544 .save_to_layer(&config, ConfigLayer::Project)
1545 .unwrap();
1546
1547 let content = std::fs::read_to_string(&project_config_path).unwrap();
1549 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1550
1551 assert!(
1553 json.get("editor").and_then(|e| e.get("tab_size")).is_none(),
1554 "Project config should NOT contain tab_size when it matches user layer"
1555 );
1556
1557 drop(temp);
1558 }
1559
1560 #[test]
1568 fn issue_630_save_to_file_strips_settings_matching_defaults() {
1569 let (_temp, resolver) = create_test_resolver();
1570
1571 let user_config_path = resolver.user_config_path();
1573 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1574 std::fs::write(
1575 &user_config_path,
1576 r#"{
1577 "theme": "dracula",
1578 "editor": {
1579 "tab_size": 2
1580 }
1581 }"#,
1582 )
1583 .unwrap();
1584
1585 let mut config = resolver.resolve().unwrap();
1587 assert_eq!(config.theme.0, "dracula");
1588 assert_eq!(config.editor.tab_size, 2);
1589
1590 if let Some(lsp_configs) = config.lsp.get_mut("python") {
1592 for c in lsp_configs.as_mut_slice().iter_mut() {
1593 c.enabled = false;
1594 }
1595 }
1596
1597 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1599
1600 let content = std::fs::read_to_string(&user_config_path).unwrap();
1602 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1603
1604 eprintln!(
1605 "Saved config:\n{}",
1606 serde_json::to_string_pretty(&json).unwrap()
1607 );
1608
1609 assert_eq!(
1611 json.get("theme").and_then(|v| v.as_str()),
1612 Some("dracula"),
1613 "Theme should be saved (differs from default)"
1614 );
1615 assert_eq!(
1616 json.get("editor")
1617 .and_then(|e| e.get("tab_size"))
1618 .and_then(|v| v.as_u64()),
1619 Some(2),
1620 "tab_size should be saved (differs from default)"
1621 );
1622 assert_eq!(
1623 json.get("lsp")
1624 .and_then(|l| l.get("python"))
1625 .and_then(|p| p.get("enabled"))
1626 .and_then(|v| v.as_bool()),
1627 Some(false),
1628 "lsp.python.enabled should be saved (differs from default)"
1629 );
1630
1631 let reloaded = resolver.resolve().unwrap();
1633 assert_eq!(reloaded.theme.0, "dracula");
1634 assert_eq!(reloaded.editor.tab_size, 2);
1635 assert!(!reloaded.lsp["python"].as_slice()[0].enabled);
1636 assert_eq!(reloaded.lsp["python"].as_slice()[0].command, "pylsp");
1638 }
1639
1640 #[test]
1647 fn toggle_lsp_preserves_command() {
1648 let (_temp, resolver) = create_test_resolver();
1649 let user_config_path = resolver.user_config_path();
1650 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1651
1652 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1654
1655 let config = resolver.resolve().unwrap();
1657 let original_command = config.lsp["python"].as_slice()[0].command.clone();
1658 assert!(
1659 !original_command.is_empty(),
1660 "Default python LSP should have a command"
1661 );
1662
1663 let mut config = resolver.resolve().unwrap();
1665 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = false;
1666 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1667
1668 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1670 assert!(
1671 !saved_content.contains(r#""command""#),
1672 "Saved config should not contain 'command' field. File content: {}",
1673 saved_content
1674 );
1675 assert!(
1676 !saved_content.contains(r#""args""#),
1677 "Saved config should not contain 'args' field. File content: {}",
1678 saved_content
1679 );
1680
1681 let mut config = resolver.resolve().unwrap();
1683 assert!(!config.lsp["python"].as_slice()[0].enabled);
1684 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = true;
1685 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1686
1687 let config = resolver.resolve().unwrap();
1689 assert_eq!(
1690 config.lsp["python"].as_slice()[0].command,
1691 original_command,
1692 "Command should be preserved after toggling enabled. Got: '{}'",
1693 config.lsp["python"].as_slice()[0].command
1694 );
1695 }
1696
1697 #[test]
1708 fn issue_631_disabled_lsp_without_command_should_be_valid() {
1709 let (_temp, resolver) = create_test_resolver();
1710
1711 let user_config_path = resolver.user_config_path();
1713 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1714 std::fs::write(
1715 &user_config_path,
1716 r#"{
1717 "lsp": {
1718 "json": { "enabled": false },
1719 "python": { "enabled": false },
1720 "toml": { "enabled": false }
1721 },
1722 "theme": "dracula"
1723 }"#,
1724 )
1725 .unwrap();
1726
1727 let result = resolver.resolve();
1729
1730 assert!(
1733 result.is_ok(),
1734 "BUG #631: Config with disabled LSP should be valid even without 'command' field. \
1735 Got parse error: {:?}",
1736 result.err()
1737 );
1738
1739 let config = result.unwrap();
1741 assert_eq!(
1742 config.theme.0, "dracula",
1743 "Theme should be 'dracula' from config file"
1744 );
1745 }
1746
1747 #[test]
1749 fn loading_lsp_without_command_uses_default() {
1750 let (_temp, resolver) = create_test_resolver();
1751 let user_config_path = resolver.user_config_path();
1752 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1753
1754 std::fs::write(
1756 &user_config_path,
1757 r#"{ "lsp": { "rust": { "enabled": false } } }"#,
1758 )
1759 .unwrap();
1760
1761 let config = resolver.resolve().unwrap();
1763 assert_eq!(
1764 config.lsp["rust"].as_slice()[0].command,
1765 "rust-analyzer",
1766 "Command should come from defaults when not in file. Got: '{}'",
1767 config.lsp["rust"].as_slice()[0].command
1768 );
1769 assert!(
1770 !config.lsp["rust"].as_slice()[0].enabled,
1771 "enabled should be false from file"
1772 );
1773 }
1774
1775 #[test]
1781 fn settings_ui_toggle_lsp_preserves_command() {
1782 let (_temp, resolver) = create_test_resolver();
1783 let user_config_path = resolver.user_config_path();
1784 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1785
1786 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1788
1789 let config = resolver.resolve().unwrap();
1791 assert_eq!(
1792 config.lsp["rust"].as_slice()[0].command,
1793 "rust-analyzer",
1794 "Default rust command should be rust-analyzer"
1795 );
1796 assert!(
1797 config.lsp["rust"].as_slice()[0].enabled,
1798 "Default rust enabled should be true"
1799 );
1800
1801 let mut changes = std::collections::HashMap::new();
1804 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(false));
1805 let deletions = std::collections::HashSet::new();
1806
1807 resolver
1809 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1810 .unwrap();
1811
1812 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1814 eprintln!("After disable, file contains:\n{}", saved_content);
1815
1816 let reloaded = resolver.resolve().unwrap();
1818 assert_eq!(
1819 reloaded.lsp["rust"].as_slice()[0].command,
1820 "rust-analyzer",
1821 "Command should be preserved after save/reload (disabled). Got: '{}'",
1822 reloaded.lsp["rust"].as_slice()[0].command
1823 );
1824 assert!(
1825 !reloaded.lsp["rust"].as_slice()[0].enabled,
1826 "rust should be disabled"
1827 );
1828
1829 let mut changes = std::collections::HashMap::new();
1831 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(true));
1832 let deletions = std::collections::HashSet::new();
1833
1834 resolver
1836 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1837 .unwrap();
1838
1839 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1841 eprintln!("After re-enable, file contains:\n{}", saved_content);
1842
1843 let final_config = resolver.resolve().unwrap();
1845 assert_eq!(
1846 final_config.lsp["rust"].as_slice()[0].command,
1847 "rust-analyzer",
1848 "Command should be preserved after toggle cycle. Got: '{}'",
1849 final_config.lsp["rust"].as_slice()[0].command
1850 );
1851 assert!(
1852 final_config.lsp["rust"].as_slice()[0].enabled,
1853 "rust should be enabled"
1854 );
1855 }
1856
1857 #[test]
1868 fn issue_806_manual_config_edits_lost_when_saving_from_ui() {
1869 let (_temp, resolver) = create_test_resolver();
1870 let user_config_path = resolver.user_config_path();
1871 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1872
1873 std::fs::write(
1876 &user_config_path,
1877 r#"{
1878 "lsp": {
1879 "rust-analyzer": {
1880 "enabled": true,
1881 "command": "rust-analyzer",
1882 "args": ["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1883 "languages": ["rust"]
1884 }
1885 }
1886 }"#,
1887 )
1888 .unwrap();
1889
1890 let config = resolver.resolve().unwrap();
1892
1893 assert!(
1895 config.lsp.contains_key("rust-analyzer"),
1896 "Config should contain manually-added 'rust-analyzer' LSP entry"
1897 );
1898 let rust_analyzer = &config.lsp["rust-analyzer"].as_slice()[0];
1899 assert!(rust_analyzer.enabled, "rust-analyzer should be enabled");
1900 assert_eq!(
1901 rust_analyzer.command, "rust-analyzer",
1902 "rust-analyzer command should be preserved"
1903 );
1904 assert_eq!(
1905 rust_analyzer.args,
1906 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1907 "rust-analyzer args should be preserved"
1908 );
1909
1910 let mut config_json = serde_json::to_value(&config).unwrap();
1913 *config_json
1914 .pointer_mut("/editor/tab_size")
1915 .expect("path should exist") = serde_json::json!(2);
1916 let modified_config: crate::config::Config =
1917 serde_json::from_value(config_json).expect("should deserialize");
1918
1919 resolver
1921 .save_to_layer(&modified_config, ConfigLayer::User)
1922 .unwrap();
1923
1924 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1926 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1927
1928 eprintln!(
1929 "Issue #806 - Saved config after changing tab_size:\n{}",
1930 serde_json::to_string_pretty(&saved_json).unwrap()
1931 );
1932
1933 assert!(
1935 saved_json.get("lsp").is_some(),
1936 "BUG #806: 'lsp' section should NOT be deleted when saving unrelated changes. \
1937 File content: {}",
1938 saved_content
1939 );
1940
1941 assert!(
1942 saved_json
1943 .get("lsp")
1944 .and_then(|l| l.get("rust-analyzer"))
1945 .is_some(),
1946 "BUG #806: 'lsp.rust-analyzer' should NOT be deleted when saving unrelated changes. \
1947 File content: {}",
1948 saved_content
1949 );
1950
1951 let saved_args = saved_json
1953 .get("lsp")
1954 .and_then(|l| l.get("rust-analyzer"))
1955 .and_then(|r| r.get("args"));
1956 assert!(
1957 saved_args.is_some(),
1958 "BUG #806: 'lsp.rust-analyzer.args' should be preserved. File content: {}",
1959 saved_content
1960 );
1961 assert_eq!(
1962 saved_args.unwrap(),
1963 &serde_json::json!(["--log-file", "/tmp/rust-analyzer-{pid}.log"]),
1964 "BUG #806: Custom args should be preserved exactly"
1965 );
1966
1967 assert_eq!(
1969 saved_json
1970 .get("editor")
1971 .and_then(|e| e.get("tab_size"))
1972 .and_then(|v| v.as_u64()),
1973 Some(2),
1974 "tab_size should be saved"
1975 );
1976
1977 let reloaded = resolver.resolve().unwrap();
1979 assert_eq!(
1980 reloaded.editor.tab_size, 2,
1981 "tab_size change should be persisted"
1982 );
1983 assert!(
1984 reloaded.lsp.contains_key("rust-analyzer"),
1985 "BUG #806: rust-analyzer should still exist after reload"
1986 );
1987 let reloaded_ra = &reloaded.lsp["rust-analyzer"].as_slice()[0];
1988 assert_eq!(
1989 reloaded_ra.args,
1990 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1991 "BUG #806: Custom args should survive save/reload cycle"
1992 );
1993 }
1994
1995 #[test]
2000 fn issue_806_custom_lsp_entries_preserved_across_unrelated_changes() {
2001 let (_temp, resolver) = create_test_resolver();
2002 let user_config_path = resolver.user_config_path();
2003 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2004
2005 std::fs::write(
2007 &user_config_path,
2008 r#"{
2009 "theme": "dracula",
2010 "lsp": {
2011 "my-custom-lsp": {
2012 "enabled": true,
2013 "command": "/usr/local/bin/my-custom-lsp",
2014 "args": ["--verbose", "--config", "/etc/my-lsp.json"],
2015 "languages": ["mycustomlang"]
2016 }
2017 },
2018 "languages": {
2019 "mycustomlang": {
2020 "extensions": [".mcl"],
2021 "grammar": "mycustomlang"
2022 }
2023 }
2024 }"#,
2025 )
2026 .unwrap();
2027
2028 let config = resolver.resolve().unwrap();
2030 assert!(
2031 config.lsp.contains_key("my-custom-lsp"),
2032 "Custom LSP entry should be loaded"
2033 );
2034 assert!(
2035 config.languages.contains_key("mycustomlang"),
2036 "Custom language should be loaded"
2037 );
2038
2039 let mut config_json = serde_json::to_value(&config).unwrap();
2041 *config_json
2042 .pointer_mut("/editor/line_numbers")
2043 .expect("path should exist") = serde_json::json!(false);
2044 let modified_config: crate::config::Config =
2045 serde_json::from_value(config_json).expect("should deserialize");
2046
2047 resolver
2049 .save_to_layer(&modified_config, ConfigLayer::User)
2050 .unwrap();
2051
2052 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2054 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2055
2056 eprintln!(
2057 "Saved config:\n{}",
2058 serde_json::to_string_pretty(&saved_json).unwrap()
2059 );
2060
2061 assert!(
2063 saved_json
2064 .get("lsp")
2065 .and_then(|l| l.get("my-custom-lsp"))
2066 .is_some(),
2067 "BUG #806: Custom LSP 'my-custom-lsp' should be preserved. Got: {}",
2068 saved_content
2069 );
2070
2071 assert!(
2073 saved_json
2074 .get("languages")
2075 .and_then(|l| l.get("mycustomlang"))
2076 .is_some(),
2077 "BUG #806: Custom language 'mycustomlang' should be preserved. Got: {}",
2078 saved_content
2079 );
2080
2081 let reloaded = resolver.resolve().unwrap();
2083 assert!(
2084 reloaded.lsp.contains_key("my-custom-lsp"),
2085 "Custom LSP should survive save/reload"
2086 );
2087 assert!(
2088 reloaded.languages.contains_key("mycustomlang"),
2089 "Custom language should survive save/reload"
2090 );
2091 assert!(
2092 !reloaded.editor.line_numbers,
2093 "line_numbers change should be applied"
2094 );
2095 }
2096
2097 #[test]
2110 fn issue_806_external_file_modification_lost_on_ui_save() {
2111 let (_temp, resolver) = create_test_resolver();
2112 let user_config_path = resolver.user_config_path();
2113 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2114
2115 std::fs::write(&user_config_path, r#"{"theme": "monokai"}"#).unwrap();
2117
2118 let config_at_startup = resolver.resolve().unwrap();
2120 assert_eq!(config_at_startup.theme.0, "monokai");
2121 assert!(
2122 !config_at_startup.lsp.contains_key("rust-analyzer"),
2123 "No custom LSP at startup"
2124 );
2125
2126 std::fs::write(
2129 &user_config_path,
2130 r#"{
2131 "theme": "monokai",
2132 "lsp": {
2133 "rust-analyzer": {
2134 "enabled": true,
2135 "command": "rust-analyzer",
2136 "args": ["--log-file", "/tmp/ra.log"]
2137 }
2138 }
2139 }"#,
2140 )
2141 .unwrap();
2142
2143 let mut config_json = serde_json::to_value(&config_at_startup).unwrap();
2147 *config_json
2148 .pointer_mut("/editor/tab_size")
2149 .expect("path should exist") = serde_json::json!(2);
2150 let modified_config: crate::config::Config =
2151 serde_json::from_value(config_json).expect("should deserialize");
2152
2153 resolver
2157 .save_to_layer(&modified_config, ConfigLayer::User)
2158 .unwrap();
2159
2160 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2162 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2163
2164 eprintln!(
2165 "Issue #806 scenario 2 - After UI save (external edits should be preserved):\n{}",
2166 serde_json::to_string_pretty(&saved_json).unwrap()
2167 );
2168
2169 assert!(
2175 saved_json.get("lsp").is_some(),
2176 "BUG #806: External edits to config.json were lost! \
2177 The 'lsp' section added while Fresh was running should be preserved. \
2178 Saved content: {}",
2179 saved_content
2180 );
2181
2182 assert!(
2183 saved_json
2184 .get("lsp")
2185 .and_then(|l| l.get("rust-analyzer"))
2186 .is_some(),
2187 "BUG #806: rust-analyzer config should be preserved"
2188 );
2189 }
2190
2191 #[test]
2197 fn issue_806_concurrent_modification_scenario() {
2198 let (_temp, resolver) = create_test_resolver();
2199 let user_config_path = resolver.user_config_path();
2200 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2201
2202 std::fs::write(&user_config_path, r#"{}"#).unwrap();
2204
2205 let mut config = resolver.resolve().unwrap();
2207
2208 config.editor.tab_size = 8;
2210
2211 std::fs::write(
2213 &user_config_path,
2214 r#"{
2215 "lsp": {
2216 "custom-lsp": {
2217 "enabled": true,
2218 "command": "/usr/bin/custom-lsp"
2219 }
2220 }
2221 }"#,
2222 )
2223 .unwrap();
2224
2225 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
2228
2229 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2231 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2232
2233 eprintln!(
2234 "Concurrent modification scenario result:\n{}",
2235 serde_json::to_string_pretty(&saved_json).unwrap()
2236 );
2237
2238 assert_eq!(
2240 saved_json
2241 .get("editor")
2242 .and_then(|e| e.get("tab_size"))
2243 .and_then(|v| v.as_u64()),
2244 Some(8),
2245 "Our tab_size change should be saved"
2246 );
2247
2248 let lsp_preserved = saved_json.get("lsp").is_some();
2254 if !lsp_preserved {
2255 eprintln!(
2256 "NOTE: Concurrent file modifications are lost with current implementation. \
2257 This is expected behavior but could be improved with read-modify-write pattern."
2258 );
2259 }
2260 }
2261
2262 #[test]
2272 fn save_to_layer_changing_to_default_value_should_persist() {
2273 let (_temp, resolver) = create_test_resolver();
2274 let user_config_path = resolver.user_config_path();
2275 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2276
2277 std::fs::write(&user_config_path, r#"{"theme": "dracula"}"#).unwrap();
2279
2280 let baseline = resolver.resolve().unwrap();
2282 assert_eq!(
2283 baseline.theme.0, "dracula",
2284 "Theme should be 'dracula' from file"
2285 );
2286
2287 let mut config = baseline.clone();
2289 config.theme = crate::config::ThemeName::from("high-contrast");
2290
2291 resolver
2293 .save_to_layer_with_baseline(&config, &baseline, ConfigLayer::User)
2294 .unwrap();
2295
2296 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2298 eprintln!(
2299 "Saved config after changing to default theme:\n{}",
2300 saved_content
2301 );
2302
2303 let reloaded = resolver.resolve().unwrap();
2305
2306 assert_eq!(
2308 reloaded.theme.0, "high-contrast",
2309 "Theme should be 'high-contrast' after changing to default and saving. \
2310 With save_to_layer_with_baseline, the theme field should be removed from file \
2311 so the default applies. File content: {}",
2312 saved_content
2313 );
2314 }
2315
2316 #[test]
2319 fn universal_lsp_round_trip_via_config_resolver() {
2320 let (_temp, resolver) = create_test_resolver();
2321 let user_config_path = resolver.user_config_path();
2322 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2323
2324 std::fs::write(
2326 &user_config_path,
2327 r#"{
2328 "universal_lsp": {
2329 "quicklsp": { "enabled": true, "auto_start": true }
2330 }
2331 }"#,
2332 )
2333 .unwrap();
2334
2335 let config = resolver.resolve().unwrap();
2336
2337 assert!(config.universal_lsp.contains_key("quicklsp"));
2339 let server = &config.universal_lsp["quicklsp"].as_slice()[0];
2340 assert!(server.enabled, "User override should enable quicklsp");
2341 assert!(server.auto_start, "User override should enable auto_start");
2342 assert_eq!(
2343 server.command, "quicklsp",
2344 "Command should come from defaults"
2345 );
2346 }
2347
2348 #[test]
2350 fn universal_lsp_custom_server_merges_with_defaults() {
2351 let (_temp, resolver) = create_test_resolver();
2352 let user_config_path = resolver.user_config_path();
2353 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2354
2355 std::fs::write(
2356 &user_config_path,
2357 r#"{
2358 "universal_lsp": {
2359 "my-universal-server": {
2360 "command": "my-server-bin",
2361 "enabled": true
2362 }
2363 }
2364 }"#,
2365 )
2366 .unwrap();
2367
2368 let config = resolver.resolve().unwrap();
2369
2370 assert!(
2372 config.universal_lsp.contains_key("my-universal-server"),
2373 "Custom universal server should be loaded"
2374 );
2375 assert_eq!(
2376 config.universal_lsp["my-universal-server"].as_slice()[0].command,
2377 "my-server-bin"
2378 );
2379
2380 assert!(
2382 config.universal_lsp.contains_key("quicklsp"),
2383 "Default quicklsp should be preserved when adding custom servers"
2384 );
2385 }
2386
2387 #[test]
2391 fn universal_lsp_partial_config_round_trip() {
2392 use crate::partial_config::PartialConfig;
2393
2394 let mut config = Config::default();
2395 if let Some(quicklsp) = config.universal_lsp.get_mut("quicklsp") {
2397 quicklsp.as_mut_slice()[0].enabled = true;
2398 }
2399
2400 let partial = PartialConfig::from(&config);
2402 let resolved = partial.resolve();
2403
2404 assert!(
2406 resolved.universal_lsp.contains_key("quicklsp"),
2407 "quicklsp should survive Config -> PartialConfig -> Config round trip"
2408 );
2409 assert!(
2410 resolved.universal_lsp["quicklsp"].as_slice()[0].enabled,
2411 "quicklsp enabled state should be preserved through round trip"
2412 );
2413 }
2414}