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 prompt_history_path(&self, history_name: &str) -> std::path::PathBuf {
941 let safe_name = history_name.replace(':', "_");
943 self.data_dir.join(format!("{}_history.json", safe_name))
944 }
945
946 pub fn search_history_path(&self) -> std::path::PathBuf {
948 self.prompt_history_path("search")
949 }
950
951 pub fn replace_history_path(&self) -> std::path::PathBuf {
953 self.prompt_history_path("replace")
954 }
955
956 pub fn goto_line_history_path(&self) -> std::path::PathBuf {
958 self.prompt_history_path("goto_line")
959 }
960
961 pub fn terminals_dir(&self) -> std::path::PathBuf {
963 self.data_dir.join("terminals")
964 }
965
966 pub fn terminal_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
968 let encoded = crate::workspace::encode_path_for_filename(working_dir);
969 self.terminals_dir().join(encoded)
970 }
971
972 pub fn config_path(&self) -> std::path::PathBuf {
974 self.config_dir.join(Config::FILENAME)
975 }
976
977 pub fn themes_dir(&self) -> std::path::PathBuf {
979 self.config_dir.join("themes")
980 }
981
982 pub fn grammars_dir(&self) -> std::path::PathBuf {
984 self.config_dir.join("grammars")
985 }
986
987 pub fn plugins_dir(&self) -> std::path::PathBuf {
989 self.config_dir.join("plugins")
990 }
991
992 fn default_config_dir() -> Option<std::path::PathBuf> {
999 #[cfg(target_os = "macos")]
1000 {
1001 dirs::home_dir().map(|p| p.join(".config").join("fresh"))
1002 }
1003
1004 #[cfg(not(target_os = "macos"))]
1005 {
1006 dirs::config_dir().map(|p| p.join("fresh"))
1007 }
1008 }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014 use tempfile::TempDir;
1015
1016 fn create_test_resolver() -> (TempDir, ConfigResolver) {
1017 let temp_dir = TempDir::new().unwrap();
1018 let dir_context = DirectoryContext::for_testing(temp_dir.path());
1019 let working_dir = temp_dir.path().join("project");
1020 std::fs::create_dir_all(&working_dir).unwrap();
1021 let resolver = ConfigResolver::new(dir_context, working_dir);
1022 (temp_dir, resolver)
1023 }
1024
1025 #[test]
1026 fn resolver_returns_defaults_when_no_config_files() {
1027 let (_temp, resolver) = create_test_resolver();
1028 let config = resolver.resolve().unwrap();
1029
1030 assert_eq!(config.editor.tab_size, 4);
1032 assert!(config.editor.line_numbers);
1033 }
1034
1035 #[test]
1036 fn resolver_loads_user_layer() {
1037 let (temp, resolver) = create_test_resolver();
1038
1039 let user_config_path = resolver.user_config_path();
1041 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1042 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1043
1044 let config = resolver.resolve().unwrap();
1045 assert_eq!(config.editor.tab_size, 2);
1046 assert!(config.editor.line_numbers); drop(temp);
1048 }
1049
1050 #[test]
1051 fn resolver_project_overrides_user() {
1052 let (temp, resolver) = create_test_resolver();
1053
1054 let user_config_path = resolver.user_config_path();
1056 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1057 std::fs::write(
1058 &user_config_path,
1059 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1060 )
1061 .unwrap();
1062
1063 let project_config_path = resolver.project_config_path();
1065 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1066 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1067
1068 let config = resolver.resolve().unwrap();
1069 assert_eq!(config.editor.tab_size, 8); assert!(!config.editor.line_numbers); drop(temp);
1072 }
1073
1074 #[test]
1075 fn resolver_session_overrides_all() {
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 let project_config_path = resolver.project_config_path();
1085 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1086 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 4}}"#).unwrap();
1087
1088 let session_config_path = resolver.session_config_path();
1090 std::fs::write(&session_config_path, r#"{"editor": {"tab_size": 16}}"#).unwrap();
1091
1092 let config = resolver.resolve().unwrap();
1093 assert_eq!(config.editor.tab_size, 16); drop(temp);
1095 }
1096
1097 #[test]
1098 fn layer_precedence_ordering() {
1099 assert!(ConfigLayer::Session.precedence() > ConfigLayer::Project.precedence());
1100 assert!(ConfigLayer::Project.precedence() > ConfigLayer::User.precedence());
1101 assert!(ConfigLayer::User.precedence() > ConfigLayer::System.precedence());
1102 }
1103
1104 #[test]
1105 fn save_to_system_layer_fails() {
1106 let (_temp, resolver) = create_test_resolver();
1107 let config = Config::default();
1108 let result = resolver.save_to_layer(&config, ConfigLayer::System);
1109 assert!(result.is_err());
1110 }
1111
1112 #[test]
1113 fn resolver_loads_legacy_project_config() {
1114 let (temp, resolver) = create_test_resolver();
1115
1116 let working_dir = temp.path().join("project");
1118 let legacy_path = working_dir.join("config.json");
1119 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1120
1121 let config = resolver.resolve().unwrap();
1122 assert_eq!(config.editor.tab_size, 3);
1123 drop(temp);
1124 }
1125
1126 #[test]
1127 fn resolver_prefers_new_config_over_legacy() {
1128 let (temp, resolver) = create_test_resolver();
1129
1130 let working_dir = temp.path().join("project");
1132
1133 let legacy_path = working_dir.join("config.json");
1135 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1136
1137 let new_path = working_dir.join(".fresh").join("config.json");
1139 std::fs::create_dir_all(new_path.parent().unwrap()).unwrap();
1140 std::fs::write(&new_path, r#"{"editor": {"tab_size": 5}}"#).unwrap();
1141
1142 let config = resolver.resolve().unwrap();
1143 assert_eq!(config.editor.tab_size, 5); drop(temp);
1145 }
1146
1147 #[test]
1148 fn load_with_layers_works() {
1149 let temp = TempDir::new().unwrap();
1150 let dir_context = DirectoryContext::for_testing(temp.path());
1151 let working_dir = temp.path().join("project");
1152 std::fs::create_dir_all(&working_dir).unwrap();
1153
1154 std::fs::create_dir_all(&dir_context.config_dir).unwrap();
1156 std::fs::write(dir_context.config_path(), r#"{"editor": {"tab_size": 2}}"#).unwrap();
1157
1158 let config = Config::load_with_layers(&dir_context, &working_dir);
1159 assert_eq!(config.editor.tab_size, 2);
1160 }
1161
1162 #[test]
1163 fn platform_config_overrides_user() {
1164 let (temp, resolver) = create_test_resolver();
1165
1166 let user_config_path = resolver.user_config_path();
1168 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1169 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1170
1171 if let Some(platform_path) = resolver.user_platform_config_path() {
1173 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1174
1175 let config = resolver.resolve().unwrap();
1176 assert_eq!(config.editor.tab_size, 6); }
1178 drop(temp);
1179 }
1180
1181 #[test]
1182 fn project_overrides_platform() {
1183 let (temp, resolver) = create_test_resolver();
1184
1185 let user_config_path = resolver.user_config_path();
1187 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1188 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1189
1190 if let Some(platform_path) = resolver.user_platform_config_path() {
1192 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1193 }
1194
1195 let project_config_path = resolver.project_config_path();
1197 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1198 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 10}}"#).unwrap();
1199
1200 let config = resolver.resolve().unwrap();
1201 assert_eq!(config.editor.tab_size, 10); drop(temp);
1203 }
1204
1205 #[test]
1206 fn migration_adds_version() {
1207 let input = serde_json::json!({
1208 "editor": {"tab_size": 2}
1209 });
1210
1211 let migrated = migrate_config(input).unwrap();
1212
1213 assert_eq!(
1214 migrated.get("version"),
1215 Some(&serde_json::json!(CURRENT_CONFIG_VERSION))
1216 );
1217 }
1218
1219 #[test]
1220 fn migration_v1_to_v2_injects_remote_element() {
1221 let input = serde_json::json!({
1224 "version": 1,
1225 "editor": {
1226 "status_bar": {
1227 "left": ["{filename}", "{cursor}"]
1228 }
1229 }
1230 });
1231
1232 let migrated = migrate_config(input).unwrap();
1233
1234 assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
1235 let left = migrated
1236 .pointer("/editor/status_bar/left")
1237 .and_then(|v| v.as_array())
1238 .expect("status_bar.left should remain an array");
1239 assert_eq!(left[0], serde_json::json!("{remote}"));
1240 assert_eq!(left[1], serde_json::json!("{filename}"));
1241 assert_eq!(left[2], serde_json::json!("{cursor}"));
1242 }
1243
1244 #[test]
1245 fn migration_v1_to_v2_is_idempotent() {
1246 let input = serde_json::json!({
1249 "version": 1,
1250 "editor": {
1251 "status_bar": {
1252 "left": ["{filename}", "{remote}", "{cursor}"]
1253 }
1254 }
1255 });
1256
1257 let migrated = migrate_config(input).unwrap();
1258
1259 let left = migrated
1260 .pointer("/editor/status_bar/left")
1261 .and_then(|v| v.as_array())
1262 .unwrap();
1263 let remote_count = left
1264 .iter()
1265 .filter(|v| v.as_str() == Some("{remote}"))
1266 .count();
1267 assert_eq!(
1268 remote_count, 1,
1269 "migration should never duplicate an existing {{remote}} entry; left = {:?}",
1270 left
1271 );
1272 }
1273
1274 #[test]
1275 fn migration_v1_to_v2_leaves_default_users_alone() {
1276 let input = serde_json::json!({
1280 "version": 1,
1281 "editor": {"tab_size": 4}
1282 });
1283
1284 let migrated = migrate_config(input).unwrap();
1285
1286 assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
1287 assert!(
1288 migrated.pointer("/editor/status_bar").is_none(),
1289 "migration must not fabricate a status_bar object for users \
1290 who never overrode the default; migrated = {:?}",
1291 migrated
1292 );
1293 }
1294
1295 #[test]
1296 fn migration_renames_camelcase_keys() {
1297 let input = serde_json::json!({
1298 "editor": {
1299 "tabSize": 8,
1300 "lineNumbers": false
1301 }
1302 });
1303
1304 let migrated = migrate_config(input).unwrap();
1305
1306 let editor = migrated.get("editor").unwrap();
1307 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(8)));
1308 assert_eq!(editor.get("line_numbers"), Some(&serde_json::json!(false)));
1309 assert!(editor.get("tabSize").is_none());
1310 assert!(editor.get("lineNumbers").is_none());
1311 }
1312
1313 #[test]
1314 fn migration_preserves_existing_snake_case() {
1315 let input = serde_json::json!({
1316 "version": 1,
1317 "editor": {"tab_size": 4}
1318 });
1319
1320 let migrated = migrate_config(input).unwrap();
1321
1322 let editor = migrated.get("editor").unwrap();
1323 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(4)));
1324 }
1325
1326 #[test]
1327 fn resolver_loads_legacy_camelcase_config() {
1328 let (temp, resolver) = create_test_resolver();
1329
1330 let user_config_path = resolver.user_config_path();
1332 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1333 std::fs::write(
1334 &user_config_path,
1335 r#"{"editor": {"tabSize": 3, "lineNumbers": false}}"#,
1336 )
1337 .unwrap();
1338
1339 let config = resolver.resolve().unwrap();
1340 assert_eq!(config.editor.tab_size, 3);
1341 assert!(!config.editor.line_numbers);
1342 drop(temp);
1343 }
1344
1345 #[test]
1346 fn resolver_migrates_v1_status_bar_left_on_load() {
1347 let (temp, resolver) = create_test_resolver();
1352
1353 let user_config_path = resolver.user_config_path();
1354 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1355 std::fs::write(
1356 &user_config_path,
1357 r#"{
1358 "version": 1,
1359 "editor": {
1360 "status_bar": {
1361 "left": ["{filename}", "{cursor}"],
1362 "right": []
1363 }
1364 }
1365 }"#,
1366 )
1367 .unwrap();
1368
1369 let config = resolver.resolve().unwrap();
1370 let left = &config.editor.status_bar.left;
1371 assert_eq!(
1372 left.first().cloned(),
1373 Some(crate::config::StatusBarElement::RemoteIndicator),
1374 "resolver should inject RemoteIndicator at index 0 during v1→v2 \
1375 migration; left = {:?}",
1376 left
1377 );
1378 drop(temp);
1379 }
1380
1381 #[test]
1382 fn save_and_load_session() {
1383 let (_temp, resolver) = create_test_resolver();
1384
1385 let mut session = SessionConfig::new();
1386 session.set_theme(crate::config::ThemeName::from("dark"));
1387 session.set_editor_option(|e| e.tab_size = Some(2));
1388
1389 resolver.save_session(&session).unwrap();
1391
1392 let loaded = resolver.load_session().unwrap();
1394 assert_eq!(loaded.theme, Some(crate::config::ThemeName::from("dark")));
1395 assert_eq!(loaded.editor.as_ref().unwrap().tab_size, Some(2));
1396 }
1397
1398 #[test]
1399 fn clear_session_removes_file() {
1400 let (_temp, resolver) = create_test_resolver();
1401
1402 let mut session = SessionConfig::new();
1403 session.set_theme(crate::config::ThemeName::from("dark"));
1404
1405 resolver.save_session(&session).unwrap();
1407 assert!(resolver.session_config_path().exists());
1408
1409 resolver.clear_session().unwrap();
1410 assert!(!resolver.session_config_path().exists());
1411 }
1412
1413 #[test]
1414 fn load_session_returns_empty_when_no_file() {
1415 let (_temp, resolver) = create_test_resolver();
1416
1417 let session = resolver.load_session().unwrap();
1418 assert!(session.is_empty());
1419 }
1420
1421 #[test]
1422 fn session_affects_resolved_config() {
1423 let (_temp, resolver) = create_test_resolver();
1424
1425 let mut session = SessionConfig::new();
1427 session.set_editor_option(|e| e.tab_size = Some(16));
1428 resolver.save_session(&session).unwrap();
1429
1430 let config = resolver.resolve().unwrap();
1432 assert_eq!(config.editor.tab_size, 16);
1433 }
1434
1435 #[test]
1436 fn save_to_layer_writes_minimal_delta() {
1437 let (temp, resolver) = create_test_resolver();
1438
1439 let user_config_path = resolver.user_config_path();
1441 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1442 std::fs::write(
1443 &user_config_path,
1444 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1445 )
1446 .unwrap();
1447
1448 let mut config = resolver.resolve().unwrap();
1450 assert_eq!(config.editor.tab_size, 2);
1451 assert!(!config.editor.line_numbers);
1452
1453 config.editor.tab_size = 8;
1455
1456 resolver
1458 .save_to_layer(&config, ConfigLayer::Project)
1459 .unwrap();
1460
1461 let project_config_path = resolver.project_config_write_path();
1463 let content = std::fs::read_to_string(&project_config_path).unwrap();
1464 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1465
1466 assert_eq!(
1468 json.get("editor").and_then(|e| e.get("tab_size")),
1469 Some(&serde_json::json!(8)),
1470 "Project config should contain tab_size override"
1471 );
1472
1473 assert!(
1475 json.get("editor")
1476 .and_then(|e| e.get("line_numbers"))
1477 .is_none(),
1478 "Project config should NOT contain line_numbers (it's inherited from user layer)"
1479 );
1480
1481 assert!(
1483 json.get("editor")
1484 .and_then(|e| e.get("scroll_offset"))
1485 .is_none(),
1486 "Project config should NOT contain scroll_offset (it's a system default)"
1487 );
1488
1489 drop(temp);
1490 }
1491
1492 #[test]
1498 #[ignore = "Known limitation: save_to_layer cannot remove values that match parent layer"]
1499 fn save_to_layer_removes_inherited_values() {
1500 let (temp, resolver) = create_test_resolver();
1501
1502 let user_config_path = resolver.user_config_path();
1504 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1505 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1506
1507 let project_config_path = resolver.project_config_write_path();
1509 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1510 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1511
1512 let mut config = resolver.resolve().unwrap();
1514 assert_eq!(config.editor.tab_size, 8);
1515
1516 config.editor.tab_size = 2;
1518
1519 resolver
1521 .save_to_layer(&config, ConfigLayer::Project)
1522 .unwrap();
1523
1524 let content = std::fs::read_to_string(&project_config_path).unwrap();
1526 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1527
1528 assert!(
1530 json.get("editor").and_then(|e| e.get("tab_size")).is_none(),
1531 "Project config should NOT contain tab_size when it matches user layer"
1532 );
1533
1534 drop(temp);
1535 }
1536
1537 #[test]
1545 fn issue_630_save_to_file_strips_settings_matching_defaults() {
1546 let (_temp, resolver) = create_test_resolver();
1547
1548 let user_config_path = resolver.user_config_path();
1550 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1551 std::fs::write(
1552 &user_config_path,
1553 r#"{
1554 "theme": "dracula",
1555 "editor": {
1556 "tab_size": 2
1557 }
1558 }"#,
1559 )
1560 .unwrap();
1561
1562 let mut config = resolver.resolve().unwrap();
1564 assert_eq!(config.theme.0, "dracula");
1565 assert_eq!(config.editor.tab_size, 2);
1566
1567 if let Some(lsp_configs) = config.lsp.get_mut("python") {
1569 for c in lsp_configs.as_mut_slice().iter_mut() {
1570 c.enabled = false;
1571 }
1572 }
1573
1574 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1576
1577 let content = std::fs::read_to_string(&user_config_path).unwrap();
1579 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1580
1581 eprintln!(
1582 "Saved config:\n{}",
1583 serde_json::to_string_pretty(&json).unwrap()
1584 );
1585
1586 assert_eq!(
1588 json.get("theme").and_then(|v| v.as_str()),
1589 Some("dracula"),
1590 "Theme should be saved (differs from default)"
1591 );
1592 assert_eq!(
1593 json.get("editor")
1594 .and_then(|e| e.get("tab_size"))
1595 .and_then(|v| v.as_u64()),
1596 Some(2),
1597 "tab_size should be saved (differs from default)"
1598 );
1599 assert_eq!(
1600 json.get("lsp")
1601 .and_then(|l| l.get("python"))
1602 .and_then(|p| p.get("enabled"))
1603 .and_then(|v| v.as_bool()),
1604 Some(false),
1605 "lsp.python.enabled should be saved (differs from default)"
1606 );
1607
1608 let reloaded = resolver.resolve().unwrap();
1610 assert_eq!(reloaded.theme.0, "dracula");
1611 assert_eq!(reloaded.editor.tab_size, 2);
1612 assert!(!reloaded.lsp["python"].as_slice()[0].enabled);
1613 assert_eq!(reloaded.lsp["python"].as_slice()[0].command, "pylsp");
1615 }
1616
1617 #[test]
1624 fn toggle_lsp_preserves_command() {
1625 let (_temp, resolver) = create_test_resolver();
1626 let user_config_path = resolver.user_config_path();
1627 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1628
1629 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1631
1632 let config = resolver.resolve().unwrap();
1634 let original_command = config.lsp["python"].as_slice()[0].command.clone();
1635 assert!(
1636 !original_command.is_empty(),
1637 "Default python LSP should have a command"
1638 );
1639
1640 let mut config = resolver.resolve().unwrap();
1642 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = false;
1643 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1644
1645 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1647 assert!(
1648 !saved_content.contains(r#""command""#),
1649 "Saved config should not contain 'command' field. File content: {}",
1650 saved_content
1651 );
1652 assert!(
1653 !saved_content.contains(r#""args""#),
1654 "Saved config should not contain 'args' field. File content: {}",
1655 saved_content
1656 );
1657
1658 let mut config = resolver.resolve().unwrap();
1660 assert!(!config.lsp["python"].as_slice()[0].enabled);
1661 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = true;
1662 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1663
1664 let config = resolver.resolve().unwrap();
1666 assert_eq!(
1667 config.lsp["python"].as_slice()[0].command,
1668 original_command,
1669 "Command should be preserved after toggling enabled. Got: '{}'",
1670 config.lsp["python"].as_slice()[0].command
1671 );
1672 }
1673
1674 #[test]
1685 fn issue_631_disabled_lsp_without_command_should_be_valid() {
1686 let (_temp, resolver) = create_test_resolver();
1687
1688 let user_config_path = resolver.user_config_path();
1690 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1691 std::fs::write(
1692 &user_config_path,
1693 r#"{
1694 "lsp": {
1695 "json": { "enabled": false },
1696 "python": { "enabled": false },
1697 "toml": { "enabled": false }
1698 },
1699 "theme": "dracula"
1700 }"#,
1701 )
1702 .unwrap();
1703
1704 let result = resolver.resolve();
1706
1707 assert!(
1710 result.is_ok(),
1711 "BUG #631: Config with disabled LSP should be valid even without 'command' field. \
1712 Got parse error: {:?}",
1713 result.err()
1714 );
1715
1716 let config = result.unwrap();
1718 assert_eq!(
1719 config.theme.0, "dracula",
1720 "Theme should be 'dracula' from config file"
1721 );
1722 }
1723
1724 #[test]
1726 fn loading_lsp_without_command_uses_default() {
1727 let (_temp, resolver) = create_test_resolver();
1728 let user_config_path = resolver.user_config_path();
1729 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1730
1731 std::fs::write(
1733 &user_config_path,
1734 r#"{ "lsp": { "rust": { "enabled": false } } }"#,
1735 )
1736 .unwrap();
1737
1738 let config = resolver.resolve().unwrap();
1740 assert_eq!(
1741 config.lsp["rust"].as_slice()[0].command,
1742 "rust-analyzer",
1743 "Command should come from defaults when not in file. Got: '{}'",
1744 config.lsp["rust"].as_slice()[0].command
1745 );
1746 assert!(
1747 !config.lsp["rust"].as_slice()[0].enabled,
1748 "enabled should be false from file"
1749 );
1750 }
1751
1752 #[test]
1758 fn settings_ui_toggle_lsp_preserves_command() {
1759 let (_temp, resolver) = create_test_resolver();
1760 let user_config_path = resolver.user_config_path();
1761 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1762
1763 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1765
1766 let config = resolver.resolve().unwrap();
1768 assert_eq!(
1769 config.lsp["rust"].as_slice()[0].command,
1770 "rust-analyzer",
1771 "Default rust command should be rust-analyzer"
1772 );
1773 assert!(
1774 config.lsp["rust"].as_slice()[0].enabled,
1775 "Default rust enabled should be true"
1776 );
1777
1778 let mut changes = std::collections::HashMap::new();
1781 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(false));
1782 let deletions = std::collections::HashSet::new();
1783
1784 resolver
1786 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1787 .unwrap();
1788
1789 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1791 eprintln!("After disable, file contains:\n{}", saved_content);
1792
1793 let reloaded = resolver.resolve().unwrap();
1795 assert_eq!(
1796 reloaded.lsp["rust"].as_slice()[0].command,
1797 "rust-analyzer",
1798 "Command should be preserved after save/reload (disabled). Got: '{}'",
1799 reloaded.lsp["rust"].as_slice()[0].command
1800 );
1801 assert!(
1802 !reloaded.lsp["rust"].as_slice()[0].enabled,
1803 "rust should be disabled"
1804 );
1805
1806 let mut changes = std::collections::HashMap::new();
1808 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(true));
1809 let deletions = std::collections::HashSet::new();
1810
1811 resolver
1813 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1814 .unwrap();
1815
1816 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1818 eprintln!("After re-enable, file contains:\n{}", saved_content);
1819
1820 let final_config = resolver.resolve().unwrap();
1822 assert_eq!(
1823 final_config.lsp["rust"].as_slice()[0].command,
1824 "rust-analyzer",
1825 "Command should be preserved after toggle cycle. Got: '{}'",
1826 final_config.lsp["rust"].as_slice()[0].command
1827 );
1828 assert!(
1829 final_config.lsp["rust"].as_slice()[0].enabled,
1830 "rust should be enabled"
1831 );
1832 }
1833
1834 #[test]
1845 fn issue_806_manual_config_edits_lost_when_saving_from_ui() {
1846 let (_temp, resolver) = create_test_resolver();
1847 let user_config_path = resolver.user_config_path();
1848 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1849
1850 std::fs::write(
1853 &user_config_path,
1854 r#"{
1855 "lsp": {
1856 "rust-analyzer": {
1857 "enabled": true,
1858 "command": "rust-analyzer",
1859 "args": ["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1860 "languages": ["rust"]
1861 }
1862 }
1863 }"#,
1864 )
1865 .unwrap();
1866
1867 let config = resolver.resolve().unwrap();
1869
1870 assert!(
1872 config.lsp.contains_key("rust-analyzer"),
1873 "Config should contain manually-added 'rust-analyzer' LSP entry"
1874 );
1875 let rust_analyzer = &config.lsp["rust-analyzer"].as_slice()[0];
1876 assert!(rust_analyzer.enabled, "rust-analyzer should be enabled");
1877 assert_eq!(
1878 rust_analyzer.command, "rust-analyzer",
1879 "rust-analyzer command should be preserved"
1880 );
1881 assert_eq!(
1882 rust_analyzer.args,
1883 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1884 "rust-analyzer args should be preserved"
1885 );
1886
1887 let mut config_json = serde_json::to_value(&config).unwrap();
1890 *config_json
1891 .pointer_mut("/editor/tab_size")
1892 .expect("path should exist") = serde_json::json!(2);
1893 let modified_config: crate::config::Config =
1894 serde_json::from_value(config_json).expect("should deserialize");
1895
1896 resolver
1898 .save_to_layer(&modified_config, ConfigLayer::User)
1899 .unwrap();
1900
1901 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1903 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1904
1905 eprintln!(
1906 "Issue #806 - Saved config after changing tab_size:\n{}",
1907 serde_json::to_string_pretty(&saved_json).unwrap()
1908 );
1909
1910 assert!(
1912 saved_json.get("lsp").is_some(),
1913 "BUG #806: 'lsp' section should NOT be deleted when saving unrelated changes. \
1914 File content: {}",
1915 saved_content
1916 );
1917
1918 assert!(
1919 saved_json
1920 .get("lsp")
1921 .and_then(|l| l.get("rust-analyzer"))
1922 .is_some(),
1923 "BUG #806: 'lsp.rust-analyzer' should NOT be deleted when saving unrelated changes. \
1924 File content: {}",
1925 saved_content
1926 );
1927
1928 let saved_args = saved_json
1930 .get("lsp")
1931 .and_then(|l| l.get("rust-analyzer"))
1932 .and_then(|r| r.get("args"));
1933 assert!(
1934 saved_args.is_some(),
1935 "BUG #806: 'lsp.rust-analyzer.args' should be preserved. File content: {}",
1936 saved_content
1937 );
1938 assert_eq!(
1939 saved_args.unwrap(),
1940 &serde_json::json!(["--log-file", "/tmp/rust-analyzer-{pid}.log"]),
1941 "BUG #806: Custom args should be preserved exactly"
1942 );
1943
1944 assert_eq!(
1946 saved_json
1947 .get("editor")
1948 .and_then(|e| e.get("tab_size"))
1949 .and_then(|v| v.as_u64()),
1950 Some(2),
1951 "tab_size should be saved"
1952 );
1953
1954 let reloaded = resolver.resolve().unwrap();
1956 assert_eq!(
1957 reloaded.editor.tab_size, 2,
1958 "tab_size change should be persisted"
1959 );
1960 assert!(
1961 reloaded.lsp.contains_key("rust-analyzer"),
1962 "BUG #806: rust-analyzer should still exist after reload"
1963 );
1964 let reloaded_ra = &reloaded.lsp["rust-analyzer"].as_slice()[0];
1965 assert_eq!(
1966 reloaded_ra.args,
1967 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1968 "BUG #806: Custom args should survive save/reload cycle"
1969 );
1970 }
1971
1972 #[test]
1977 fn issue_806_custom_lsp_entries_preserved_across_unrelated_changes() {
1978 let (_temp, resolver) = create_test_resolver();
1979 let user_config_path = resolver.user_config_path();
1980 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1981
1982 std::fs::write(
1984 &user_config_path,
1985 r#"{
1986 "theme": "dracula",
1987 "lsp": {
1988 "my-custom-lsp": {
1989 "enabled": true,
1990 "command": "/usr/local/bin/my-custom-lsp",
1991 "args": ["--verbose", "--config", "/etc/my-lsp.json"],
1992 "languages": ["mycustomlang"]
1993 }
1994 },
1995 "languages": {
1996 "mycustomlang": {
1997 "extensions": [".mcl"],
1998 "grammar": "mycustomlang"
1999 }
2000 }
2001 }"#,
2002 )
2003 .unwrap();
2004
2005 let config = resolver.resolve().unwrap();
2007 assert!(
2008 config.lsp.contains_key("my-custom-lsp"),
2009 "Custom LSP entry should be loaded"
2010 );
2011 assert!(
2012 config.languages.contains_key("mycustomlang"),
2013 "Custom language should be loaded"
2014 );
2015
2016 let mut config_json = serde_json::to_value(&config).unwrap();
2018 *config_json
2019 .pointer_mut("/editor/line_numbers")
2020 .expect("path should exist") = serde_json::json!(false);
2021 let modified_config: crate::config::Config =
2022 serde_json::from_value(config_json).expect("should deserialize");
2023
2024 resolver
2026 .save_to_layer(&modified_config, ConfigLayer::User)
2027 .unwrap();
2028
2029 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2031 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2032
2033 eprintln!(
2034 "Saved config:\n{}",
2035 serde_json::to_string_pretty(&saved_json).unwrap()
2036 );
2037
2038 assert!(
2040 saved_json
2041 .get("lsp")
2042 .and_then(|l| l.get("my-custom-lsp"))
2043 .is_some(),
2044 "BUG #806: Custom LSP 'my-custom-lsp' should be preserved. Got: {}",
2045 saved_content
2046 );
2047
2048 assert!(
2050 saved_json
2051 .get("languages")
2052 .and_then(|l| l.get("mycustomlang"))
2053 .is_some(),
2054 "BUG #806: Custom language 'mycustomlang' should be preserved. Got: {}",
2055 saved_content
2056 );
2057
2058 let reloaded = resolver.resolve().unwrap();
2060 assert!(
2061 reloaded.lsp.contains_key("my-custom-lsp"),
2062 "Custom LSP should survive save/reload"
2063 );
2064 assert!(
2065 reloaded.languages.contains_key("mycustomlang"),
2066 "Custom language should survive save/reload"
2067 );
2068 assert!(
2069 !reloaded.editor.line_numbers,
2070 "line_numbers change should be applied"
2071 );
2072 }
2073
2074 #[test]
2087 fn issue_806_external_file_modification_lost_on_ui_save() {
2088 let (_temp, resolver) = create_test_resolver();
2089 let user_config_path = resolver.user_config_path();
2090 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2091
2092 std::fs::write(&user_config_path, r#"{"theme": "monokai"}"#).unwrap();
2094
2095 let config_at_startup = resolver.resolve().unwrap();
2097 assert_eq!(config_at_startup.theme.0, "monokai");
2098 assert!(
2099 !config_at_startup.lsp.contains_key("rust-analyzer"),
2100 "No custom LSP at startup"
2101 );
2102
2103 std::fs::write(
2106 &user_config_path,
2107 r#"{
2108 "theme": "monokai",
2109 "lsp": {
2110 "rust-analyzer": {
2111 "enabled": true,
2112 "command": "rust-analyzer",
2113 "args": ["--log-file", "/tmp/ra.log"]
2114 }
2115 }
2116 }"#,
2117 )
2118 .unwrap();
2119
2120 let mut config_json = serde_json::to_value(&config_at_startup).unwrap();
2124 *config_json
2125 .pointer_mut("/editor/tab_size")
2126 .expect("path should exist") = serde_json::json!(2);
2127 let modified_config: crate::config::Config =
2128 serde_json::from_value(config_json).expect("should deserialize");
2129
2130 resolver
2134 .save_to_layer(&modified_config, ConfigLayer::User)
2135 .unwrap();
2136
2137 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2139 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2140
2141 eprintln!(
2142 "Issue #806 scenario 2 - After UI save (external edits should be preserved):\n{}",
2143 serde_json::to_string_pretty(&saved_json).unwrap()
2144 );
2145
2146 assert!(
2152 saved_json.get("lsp").is_some(),
2153 "BUG #806: External edits to config.json were lost! \
2154 The 'lsp' section added while Fresh was running should be preserved. \
2155 Saved content: {}",
2156 saved_content
2157 );
2158
2159 assert!(
2160 saved_json
2161 .get("lsp")
2162 .and_then(|l| l.get("rust-analyzer"))
2163 .is_some(),
2164 "BUG #806: rust-analyzer config should be preserved"
2165 );
2166 }
2167
2168 #[test]
2174 fn issue_806_concurrent_modification_scenario() {
2175 let (_temp, resolver) = create_test_resolver();
2176 let user_config_path = resolver.user_config_path();
2177 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2178
2179 std::fs::write(&user_config_path, r#"{}"#).unwrap();
2181
2182 let mut config = resolver.resolve().unwrap();
2184
2185 config.editor.tab_size = 8;
2187
2188 std::fs::write(
2190 &user_config_path,
2191 r#"{
2192 "lsp": {
2193 "custom-lsp": {
2194 "enabled": true,
2195 "command": "/usr/bin/custom-lsp"
2196 }
2197 }
2198 }"#,
2199 )
2200 .unwrap();
2201
2202 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
2205
2206 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2208 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2209
2210 eprintln!(
2211 "Concurrent modification scenario result:\n{}",
2212 serde_json::to_string_pretty(&saved_json).unwrap()
2213 );
2214
2215 assert_eq!(
2217 saved_json
2218 .get("editor")
2219 .and_then(|e| e.get("tab_size"))
2220 .and_then(|v| v.as_u64()),
2221 Some(8),
2222 "Our tab_size change should be saved"
2223 );
2224
2225 let lsp_preserved = saved_json.get("lsp").is_some();
2231 if !lsp_preserved {
2232 eprintln!(
2233 "NOTE: Concurrent file modifications are lost with current implementation. \
2234 This is expected behavior but could be improved with read-modify-write pattern."
2235 );
2236 }
2237 }
2238
2239 #[test]
2249 fn save_to_layer_changing_to_default_value_should_persist() {
2250 let (_temp, resolver) = create_test_resolver();
2251 let user_config_path = resolver.user_config_path();
2252 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2253
2254 std::fs::write(&user_config_path, r#"{"theme": "dracula"}"#).unwrap();
2256
2257 let baseline = resolver.resolve().unwrap();
2259 assert_eq!(
2260 baseline.theme.0, "dracula",
2261 "Theme should be 'dracula' from file"
2262 );
2263
2264 let mut config = baseline.clone();
2266 config.theme = crate::config::ThemeName::from("high-contrast");
2267
2268 resolver
2270 .save_to_layer_with_baseline(&config, &baseline, ConfigLayer::User)
2271 .unwrap();
2272
2273 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2275 eprintln!(
2276 "Saved config after changing to default theme:\n{}",
2277 saved_content
2278 );
2279
2280 let reloaded = resolver.resolve().unwrap();
2282
2283 assert_eq!(
2285 reloaded.theme.0, "high-contrast",
2286 "Theme should be 'high-contrast' after changing to default and saving. \
2287 With save_to_layer_with_baseline, the theme field should be removed from file \
2288 so the default applies. File content: {}",
2289 saved_content
2290 );
2291 }
2292
2293 #[test]
2296 fn universal_lsp_round_trip_via_config_resolver() {
2297 let (_temp, resolver) = create_test_resolver();
2298 let user_config_path = resolver.user_config_path();
2299 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2300
2301 std::fs::write(
2303 &user_config_path,
2304 r#"{
2305 "universal_lsp": {
2306 "quicklsp": { "enabled": true, "auto_start": true }
2307 }
2308 }"#,
2309 )
2310 .unwrap();
2311
2312 let config = resolver.resolve().unwrap();
2313
2314 assert!(config.universal_lsp.contains_key("quicklsp"));
2316 let server = &config.universal_lsp["quicklsp"].as_slice()[0];
2317 assert!(server.enabled, "User override should enable quicklsp");
2318 assert!(server.auto_start, "User override should enable auto_start");
2319 assert_eq!(
2320 server.command, "quicklsp",
2321 "Command should come from defaults"
2322 );
2323 }
2324
2325 #[test]
2327 fn universal_lsp_custom_server_merges_with_defaults() {
2328 let (_temp, resolver) = create_test_resolver();
2329 let user_config_path = resolver.user_config_path();
2330 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2331
2332 std::fs::write(
2333 &user_config_path,
2334 r#"{
2335 "universal_lsp": {
2336 "my-universal-server": {
2337 "command": "my-server-bin",
2338 "enabled": true
2339 }
2340 }
2341 }"#,
2342 )
2343 .unwrap();
2344
2345 let config = resolver.resolve().unwrap();
2346
2347 assert!(
2349 config.universal_lsp.contains_key("my-universal-server"),
2350 "Custom universal server should be loaded"
2351 );
2352 assert_eq!(
2353 config.universal_lsp["my-universal-server"].as_slice()[0].command,
2354 "my-server-bin"
2355 );
2356
2357 assert!(
2359 config.universal_lsp.contains_key("quicklsp"),
2360 "Default quicklsp should be preserved when adding custom servers"
2361 );
2362 }
2363
2364 #[test]
2368 fn universal_lsp_partial_config_round_trip() {
2369 use crate::partial_config::PartialConfig;
2370
2371 let mut config = Config::default();
2372 if let Some(quicklsp) = config.universal_lsp.get_mut("quicklsp") {
2374 quicklsp.as_mut_slice()[0].enabled = true;
2375 }
2376
2377 let partial = PartialConfig::from(&config);
2379 let resolved = partial.resolve();
2380
2381 assert!(
2383 resolved.universal_lsp.contains_key("quicklsp"),
2384 "quicklsp should survive Config -> PartialConfig -> Config round trip"
2385 );
2386 assert!(
2387 resolved.universal_lsp["quicklsp"].as_slice()[0].enabled,
2388 "quicklsp enabled state should be preserved through round trip"
2389 );
2390 }
2391}