dotx 3.2.2__tar.gz → 3.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {dotx-3.2.2/src/dotx.egg-info → dotx-3.3.0}/PKG-INFO +1 -1
  2. {dotx-3.2.2 → dotx-3.3.0}/pyproject.toml +1 -1
  3. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/cli.py +13 -1
  4. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/install.py +11 -4
  5. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/options.py +28 -1
  6. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/plan.py +40 -3
  7. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/uninstall.py +14 -6
  8. {dotx-3.2.2 → dotx-3.3.0/src/dotx.egg-info}/PKG-INFO +1 -1
  9. {dotx-3.2.2 → dotx-3.3.0}/tests/test_cli.py +90 -0
  10. {dotx-3.2.2 → dotx-3.3.0}/tests/test_install.py +88 -0
  11. {dotx-3.2.2 → dotx-3.3.0}/LICENSE +0 -0
  12. {dotx-3.2.2 → dotx-3.3.0}/MANIFEST.in +0 -0
  13. {dotx-3.2.2 → dotx-3.3.0}/README.md +0 -0
  14. {dotx-3.2.2 → dotx-3.3.0}/setup.cfg +0 -0
  15. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/__init__.py +0 -0
  16. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/always-create +0 -0
  17. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/commands/__init__.py +0 -0
  18. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/commands/database.py +0 -0
  19. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/commands/install_cmd.py +0 -0
  20. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/commands/path_cmd.py +0 -0
  21. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/commands/progress.py +0 -0
  22. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/commands/uninstall_cmd.py +0 -0
  23. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/database.py +0 -0
  24. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/dotxignore +0 -0
  25. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/hierarchy.py +0 -0
  26. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/ignore.py +0 -0
  27. {dotx-3.2.2 → dotx-3.3.0}/src/dotx/installed-schema.sql +0 -0
  28. {dotx-3.2.2 → dotx-3.3.0}/src/dotx.egg-info/SOURCES.txt +0 -0
  29. {dotx-3.2.2 → dotx-3.3.0}/src/dotx.egg-info/dependency_links.txt +0 -0
  30. {dotx-3.2.2 → dotx-3.3.0}/src/dotx.egg-info/entry_points.txt +0 -0
  31. {dotx-3.2.2 → dotx-3.3.0}/src/dotx.egg-info/requires.txt +0 -0
  32. {dotx-3.2.2 → dotx-3.3.0}/src/dotx.egg-info/top_level.txt +0 -0
  33. {dotx-3.2.2 → dotx-3.3.0}/tests/test_always_create.py +0 -0
  34. {dotx-3.2.2 → dotx-3.3.0}/tests/test_cli_database.py +0 -0
  35. {dotx-3.2.2 → dotx-3.3.0}/tests/test_ignore.py +0 -0
  36. {dotx-3.2.2 → dotx-3.3.0}/tests/test_ignore_rules.py +0 -0
  37. {dotx-3.2.2 → dotx-3.3.0}/tests/test_options.py +0 -0
  38. {dotx-3.2.2 → dotx-3.3.0}/tests/test_path_which.py +0 -0
  39. {dotx-3.2.2 → dotx-3.3.0}/tests/test_plan.py +0 -0
  40. {dotx-3.2.2 → dotx-3.3.0}/tests/test_uninstall.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotx
3
- Version: 3.2.2
3
+ Version: 3.3.0
4
4
  Summary: A command-line tool to install a link-farm to your dotfiles
5
5
  Author-email: Wolf <Wolf@zv.cx>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dotx"
3
- version = "3.2.2"
3
+ version = "3.3.0"
4
4
  description = "A command-line tool to install a link-farm to your dotfiles"
5
5
  authors = [
6
6
  { name = "Wolf", email = "Wolf@zv.cx" }
@@ -68,13 +68,20 @@ def main(
68
68
  target: Annotated[
69
69
  Path | None,
70
70
  typer.Option(
71
- help="Where to install (defaults to $HOME)",
71
+ help="Where to install (defaults to $HOME). Mutually exclusive with --xdg.",
72
72
  exists=True,
73
73
  file_okay=False,
74
74
  dir_okay=True,
75
75
  writable=True,
76
76
  ),
77
77
  ] = None,
78
+ xdg: Annotated[
79
+ bool,
80
+ typer.Option(
81
+ "--xdg/--no-xdg",
82
+ help="Use XDG Base Directory paths (.config→$XDG_CONFIG_HOME, etc). Mutually exclusive with --target.",
83
+ ),
84
+ ] = False,
78
85
  dry_run: Annotated[
79
86
  bool,
80
87
  typer.Option("--dry-run/--no-dry-run", help="Just echo; don't actually (un)install"),
@@ -91,6 +98,10 @@ def main(
91
98
  """
92
99
  configure_logging(debug, verbose, log)
93
100
 
101
+ # Check mutual exclusivity
102
+ if xdg and target is not None:
103
+ raise typer.BadParameter("--xdg and --target are mutually exclusive")
104
+
94
105
  # Store options in context for commands to access
95
106
  ctx.ensure_object(dict)
96
107
  if target:
@@ -101,6 +112,7 @@ def main(
101
112
  set_option("DEBUG", debug, ctx)
102
113
  set_option("VERBOSE", verbose, ctx)
103
114
  set_option("DRYRUN", dry_run, ctx)
115
+ set_option("XDG", xdg, ctx)
104
116
 
105
117
  if log:
106
118
  set_option("LOG", log, ctx)
@@ -23,6 +23,7 @@ from loguru import logger
23
23
 
24
24
  from dotx.hierarchy import HierarchicalPatternMatcher
25
25
  from dotx.ignore import IgnoreRules
26
+ from dotx.options import is_xdg_mode
26
27
  from dotx.plan import (
27
28
  Action,
28
29
  Plan,
@@ -30,6 +31,7 @@ from dotx.plan import (
30
31
  log_extracted_plan,
31
32
  mark_all_ancestors,
32
33
  mark_immediate_children,
34
+ resolve_destination,
33
35
  )
34
36
 
35
37
 
@@ -99,9 +101,13 @@ def plan_install(source_package_root: Path, destination_root: Path) -> Plan:
99
101
  already exists at the destination, which must be created, renamed, linked, or already exist in a way that causes
100
102
  a failure.
101
103
 
104
+ Respects XDG mode: when enabled, .config/*, .local/share/*, .cache/* are resolved to their
105
+ respective XDG Base Directory paths when checking for existing files.
106
+
102
107
  Returns: a `Plan` with all the information needed to complete an install, or to fail
103
108
  """
104
109
  plan: Plan = plan_install_paths(source_package_root)
110
+ xdg_mode = is_xdg_mode()
105
111
 
106
112
  # Load always-create patterns to determine which directories must be real (never symlinked)
107
113
  always_create_matcher = HierarchicalPatternMatcher(".always-create")
@@ -148,9 +154,10 @@ def plan_install(source_package_root: Path, destination_root: Path) -> Plan:
148
154
  if plan[child_relative_source_path].requires_rename:
149
155
  found_children_to_rename = True
150
156
  # Fail if we would overwrite an existing file or symlink pointing elsewhere
151
- destination_path = (
152
- destination_root
153
- / plan[child_relative_source_path].relative_destination_path
157
+ destination_path = resolve_destination(
158
+ plan[child_relative_source_path].relative_destination_path,
159
+ destination_root,
160
+ xdg_mode,
154
161
  )
155
162
  if destination_path.is_symlink():
156
163
  # Symlink exists - only OK if it already points to our source
@@ -174,7 +181,7 @@ def plan_install(source_package_root: Path, destination_root: Path) -> Plan:
174
181
  if current_root_path == source_package_root:
175
182
  # Package root always EXISTS (it's the target directory)
176
183
  plan[relative_root_path].action = Action.EXISTS
177
- elif (destination_root / relative_destination_root_path).exists():
184
+ elif resolve_destination(relative_destination_root_path, destination_root, xdg_mode).exists():
178
185
  # Directory already exists at destination - merge into it
179
186
  # This takes precedence over always-create because we can't create what exists
180
187
  plan[relative_root_path].action = Action.EXISTS
@@ -4,6 +4,7 @@ This module provides convenience functions for accessing user-data on the associ
4
4
  Note: typer is built on click, so we use click.Context for type annotations.
5
5
  """
6
6
 
7
+ from pathlib import Path
7
8
  from typing import Any
8
9
 
9
10
  import click
@@ -36,7 +37,8 @@ def get_option(option: str, default_for_option: Any = None, ctx: click.Context |
36
37
  """
37
38
  if ctx is None:
38
39
  # Typer uses click under the hood, so we can use click's get_current_context
39
- ctx = click.get_current_context()
40
+ # Use silent=True to return None instead of raising when no context exists
41
+ ctx = click.get_current_context(silent=True)
40
42
  if ctx is not None and ctx.obj is not None and option in ctx.obj:
41
43
  return ctx.obj[option]
42
44
  return default_for_option
@@ -55,3 +57,28 @@ def is_debug_mode(ctx: click.Context | None = None) -> bool:
55
57
  def is_dry_run(ctx: click.Context | None = None) -> bool:
56
58
  """Check if dry-run mode is enabled."""
57
59
  return get_option("DRYRUN", False, ctx)
60
+
61
+
62
+ def is_xdg_mode(ctx: click.Context | None = None) -> bool:
63
+ """Check if XDG mode is enabled."""
64
+ return get_option("XDG", False, ctx)
65
+
66
+
67
+ def get_xdg_paths() -> dict[str, Path]:
68
+ """
69
+ Get XDG Base Directory paths with defaults.
70
+
71
+ Returns a dict mapping destination prefixes to their XDG paths:
72
+ - ".config" → $XDG_CONFIG_HOME (default ~/.config)
73
+ - ".local/share" → $XDG_DATA_HOME (default ~/.local/share)
74
+ - ".cache" → $XDG_CACHE_HOME (default ~/.cache)
75
+ """
76
+ import os
77
+
78
+ home = Path.home()
79
+
80
+ return {
81
+ ".config": Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")),
82
+ ".local/share": Path(os.environ.get("XDG_DATA_HOME", home / ".local/share")),
83
+ ".cache": Path(os.environ.get("XDG_CACHE_HOME", home / ".cache")),
84
+ }
@@ -34,7 +34,7 @@ from typing import TYPE_CHECKING
34
34
  from loguru import logger
35
35
 
36
36
  from dotx.database import NoOpDB
37
- from dotx.options import is_dry_run
37
+ from dotx.options import is_dry_run, is_xdg_mode, get_xdg_paths
38
38
 
39
39
  if TYPE_CHECKING:
40
40
  from dotx.database import InstallationDB
@@ -91,6 +91,33 @@ class PlanNode:
91
91
  Plan = dict[Path, PlanNode]
92
92
 
93
93
 
94
+ def resolve_destination(relative_path: Path, default_root: Path, xdg_mode: bool = False) -> Path:
95
+ """
96
+ Resolve the absolute destination path, respecting XDG mode.
97
+
98
+ In normal mode: default_root / relative_path
99
+ In XDG mode: redirects .config/*, .local/share/*, .cache/* to XDG directories
100
+ """
101
+ if not xdg_mode:
102
+ return default_root / relative_path
103
+
104
+ path_str = str(relative_path)
105
+ xdg_paths = get_xdg_paths()
106
+
107
+ # Check XDG prefixes in order of specificity (longer first)
108
+ for prefix in sorted(xdg_paths.keys(), key=len, reverse=True):
109
+ if path_str == prefix:
110
+ # Exact match - return the XDG dir itself
111
+ return xdg_paths[prefix]
112
+ if path_str.startswith(prefix + "/"):
113
+ # Path under XDG dir - remap it
114
+ suffix = path_str[len(prefix) + 1:]
115
+ return xdg_paths[prefix] / suffix
116
+
117
+ # No XDG prefix match - use default (HOME)
118
+ return default_root / relative_path
119
+
120
+
94
121
  def execute_plan(
95
122
  source_package_root: Path,
96
123
  destination_root: Path,
@@ -104,18 +131,28 @@ def execute_plan(
104
131
  links, or unlinks files using pathlib native functions.
105
132
 
106
133
  If a database is provided, records installations (CREATE, LINK) and removals (UNLINK).
134
+
135
+ Respects XDG mode: when enabled, .config/*, .local/share/*, .cache/* are redirected
136
+ to their respective XDG Base Directory paths.
107
137
  """
108
138
  # Use NoOpDB if no database provided
109
139
  working_db = db if db is not None else NoOpDB()
110
140
 
141
+ # Check XDG mode
142
+ xdg_mode = is_xdg_mode()
143
+
111
144
  # Extract package info for database tracking
112
145
  package_root = source_package_root.parent
113
146
  package_name = source_package_root.name
114
147
 
148
+ def get_destination(step: PlanNode) -> Path:
149
+ """Get the absolute destination path, respecting XDG mode."""
150
+ return resolve_destination(step.relative_destination_path, destination_root, xdg_mode)
151
+
115
152
  def build_shell_command(step: PlanNode):
116
153
  """Print the shell command corresponding to exactly one `PlanNode`"""
117
154
  command = None
118
- destination = destination_root / step.relative_destination_path
155
+ destination = get_destination(step)
119
156
  source = (source_package_root / step.relative_source_path).resolve()
120
157
  try:
121
158
  source = source.relative_to(destination.parent)
@@ -147,7 +184,7 @@ def execute_plan(
147
184
 
148
185
  Records installations and removals in database.
149
186
  """
150
- destination = destination_root / step.relative_destination_path
187
+ destination = get_destination(step)
151
188
  source = (source_package_root / step.relative_source_path).resolve()
152
189
  try:
153
190
  source = source.relative_to(destination.parent)
@@ -11,7 +11,8 @@ Exported functions:
11
11
  import os
12
12
  from pathlib import Path
13
13
 
14
- from dotx.plan import Action, Plan, mark_all_descendents
14
+ from dotx.options import is_xdg_mode
15
+ from dotx.plan import Action, Plan, mark_all_descendents, resolve_destination
15
16
  from dotx.install import plan_install_paths
16
17
 
17
18
 
@@ -19,9 +20,13 @@ def plan_uninstall(source_package_root: Path, destination_root: Path) -> Plan:
19
20
  """
20
21
  Create a plan to uninstall files from destination_root that link to source_package_root.
21
22
 
23
+ Respects XDG mode: when enabled, .config/*, .local/share/*, .cache/* are resolved to their
24
+ respective XDG Base Directory paths when checking for symlinks to remove.
25
+
22
26
  Returns: a `Plan` with actions set to UNLINK for symlinks pointing to the source package
23
27
  """
24
28
  plan: Plan = plan_install_paths(source_package_root)
29
+ xdg_mode = is_xdg_mode()
25
30
 
26
31
  for current_root, _, child_files in os.walk(source_package_root):
27
32
  current_root_path = Path(current_root)
@@ -36,15 +41,18 @@ def plan_uninstall(source_package_root: Path, destination_root: Path) -> Plan:
36
41
  child_relative_source_path = relative_root_path / child
37
42
  if child_relative_source_path not in plan:
38
43
  continue
39
- destination_path = (
40
- destination_root
41
- / plan[child_relative_source_path].relative_destination_path
44
+ destination_path = resolve_destination(
45
+ plan[child_relative_source_path].relative_destination_path,
46
+ destination_root,
47
+ xdg_mode,
42
48
  )
43
49
  if destination_path.is_symlink():
44
50
  plan[child_relative_source_path].action = Action.UNLINK
45
51
 
46
- destination_path = (
47
- destination_root / plan[relative_root_path].relative_destination_path
52
+ destination_path = resolve_destination(
53
+ plan[relative_root_path].relative_destination_path,
54
+ destination_root,
55
+ xdg_mode,
48
56
  )
49
57
  action = None
50
58
  if not destination_path.exists():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotx
3
- Version: 3.2.2
3
+ Version: 3.3.0
4
4
  Summary: A command-line tool to install a link-farm to your dotfiles
5
5
  Author-email: Wolf <Wolf@zv.cx>
6
6
  License-Expression: MIT
@@ -299,3 +299,93 @@ def test_cli_uninstall_dry_run_shows_indicator(tmp_path, isolated_db):
299
299
  assert "rm" in result.output
300
300
  # File should still exist after dry-run
301
301
  assert (target / "file1").exists()
302
+
303
+
304
+ def test_cli_xdg_and_target_mutually_exclusive(tmp_path, isolated_db):
305
+ """Test that --xdg and --target cannot be used together."""
306
+ source = tmp_path / "source"
307
+ source.mkdir()
308
+ (source / "file1").write_text("content")
309
+
310
+ runner = CliRunner()
311
+
312
+ # Try to use both --xdg and --target
313
+ result = runner.invoke(app, ["--xdg", f"--target={tmp_path}", "install", str(source)])
314
+
315
+ assert result.exit_code != 0
316
+ assert "mutually exclusive" in result.output.lower()
317
+
318
+
319
+ def _has_symlink_ancestor(path: Path) -> bool:
320
+ """Check if any ancestor of path is a symlink."""
321
+ for parent in path.parents:
322
+ if parent.is_symlink():
323
+ return True
324
+ return path.is_symlink()
325
+
326
+
327
+ def test_cli_xdg_mode_install_to_xdg_paths(tmp_path, monkeypatch, isolated_db):
328
+ """Test that --xdg mode installs .config files to XDG_CONFIG_HOME."""
329
+ source = tmp_path / "source"
330
+ source.mkdir()
331
+
332
+ # Create .config/app directory with config file
333
+ config_dir = source / "dot-config" / "myapp"
334
+ config_dir.mkdir(parents=True)
335
+ (config_dir / "config.toml").write_text("setting = true")
336
+
337
+ # Set custom XDG_CONFIG_HOME
338
+ custom_config = tmp_path / "custom-config"
339
+ custom_config.mkdir()
340
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(custom_config))
341
+
342
+ runner = CliRunner()
343
+
344
+ # Install with --xdg mode
345
+ result = runner.invoke(app, ["--xdg", "install", str(source)])
346
+
347
+ assert result.exit_code == 0, f"Install failed: {result.output}"
348
+
349
+ # File should be accessible at XDG path
350
+ config_file = custom_config / "myapp" / "config.toml"
351
+ assert config_file.exists(), "config.toml should be accessible at XDG path"
352
+
353
+ # File should be connected to source via symlink (either directly or via ancestor)
354
+ assert _has_symlink_ancestor(config_file), "File or ancestor should be a symlink"
355
+
356
+ # Content should match source
357
+ assert config_file.read_text() == "setting = true"
358
+
359
+
360
+ def test_cli_xdg_mode_install_and_uninstall_roundtrip(tmp_path, monkeypatch, isolated_db):
361
+ """Test that --xdg mode works for both install and uninstall."""
362
+ source = tmp_path / "source"
363
+ source.mkdir()
364
+
365
+ # Create .config/app directory with config file
366
+ config_dir = source / "dot-config" / "myapp"
367
+ config_dir.mkdir(parents=True)
368
+ (config_dir / "config.toml").write_text("setting = true")
369
+
370
+ # Set custom XDG_CONFIG_HOME
371
+ custom_config = tmp_path / "custom-config"
372
+ custom_config.mkdir()
373
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(custom_config))
374
+
375
+ runner = CliRunner()
376
+
377
+ # Install with --xdg mode
378
+ result = runner.invoke(app, ["--xdg", "install", str(source)])
379
+ assert result.exit_code == 0, f"Install failed: {result.output}"
380
+
381
+ # Verify file is installed and accessible
382
+ config_file = custom_config / "myapp" / "config.toml"
383
+ assert config_file.exists(), "File should be accessible at XDG path"
384
+ assert _has_symlink_ancestor(config_file), "File or ancestor should be a symlink"
385
+
386
+ # Uninstall with --xdg mode
387
+ result = runner.invoke(app, ["--xdg", "uninstall", str(source)])
388
+ assert result.exit_code == 0, f"Uninstall failed: {result.output}"
389
+
390
+ # File should be removed (not accessible anymore)
391
+ assert not config_file.exists(), "File should be removed after uninstall"
@@ -679,3 +679,91 @@ def test_install_deep_nesting_execute(tmp_path, isolated_db):
679
679
  assert deep_file_dest.is_symlink()
680
680
  assert deep_file_dest.exists()
681
681
  assert deep_file_dest.read_text() == "deep content"
682
+
683
+
684
+ # --- XDG Mode Tests ---
685
+
686
+
687
+ def test_resolve_destination_no_xdg():
688
+ """Test that resolve_destination without XDG mode just joins paths."""
689
+ from dotx.plan import resolve_destination
690
+
691
+ default_root = Path("/home/user")
692
+ relative_path = Path(".config/app/config.toml")
693
+
694
+ result = resolve_destination(relative_path, default_root, xdg_mode=False)
695
+
696
+ assert result == Path("/home/user/.config/app/config.toml")
697
+
698
+
699
+ def test_resolve_destination_xdg_config(monkeypatch):
700
+ """Test that resolve_destination with XDG mode redirects .config paths."""
701
+ from dotx.plan import resolve_destination
702
+
703
+ # Set custom XDG_CONFIG_HOME
704
+ monkeypatch.setenv("XDG_CONFIG_HOME", "/custom/config")
705
+
706
+ default_root = Path("/home/user")
707
+ relative_path = Path(".config/app/config.toml")
708
+
709
+ result = resolve_destination(relative_path, default_root, xdg_mode=True)
710
+
711
+ assert result == Path("/custom/config/app/config.toml")
712
+
713
+
714
+ def test_resolve_destination_xdg_data(monkeypatch):
715
+ """Test that resolve_destination with XDG mode redirects .local/share paths."""
716
+ from dotx.plan import resolve_destination
717
+
718
+ # Set custom XDG_DATA_HOME
719
+ monkeypatch.setenv("XDG_DATA_HOME", "/custom/data")
720
+
721
+ default_root = Path("/home/user")
722
+ relative_path = Path(".local/share/app/data.db")
723
+
724
+ result = resolve_destination(relative_path, default_root, xdg_mode=True)
725
+
726
+ assert result == Path("/custom/data/app/data.db")
727
+
728
+
729
+ def test_resolve_destination_xdg_cache(monkeypatch):
730
+ """Test that resolve_destination with XDG mode redirects .cache paths."""
731
+ from dotx.plan import resolve_destination
732
+
733
+ # Set custom XDG_CACHE_HOME
734
+ monkeypatch.setenv("XDG_CACHE_HOME", "/custom/cache")
735
+
736
+ default_root = Path("/home/user")
737
+ relative_path = Path(".cache/app/cache.db")
738
+
739
+ result = resolve_destination(relative_path, default_root, xdg_mode=True)
740
+
741
+ assert result == Path("/custom/cache/app/cache.db")
742
+
743
+
744
+ def test_resolve_destination_xdg_non_xdg_path(monkeypatch):
745
+ """Test that paths not matching XDG prefixes still use default_root in XDG mode."""
746
+ from dotx.plan import resolve_destination
747
+
748
+ monkeypatch.setenv("XDG_CONFIG_HOME", "/custom/config")
749
+
750
+ default_root = Path("/home/user")
751
+ relative_path = Path(".bashrc")
752
+
753
+ result = resolve_destination(relative_path, default_root, xdg_mode=True)
754
+
755
+ assert result == Path("/home/user/.bashrc")
756
+
757
+
758
+ def test_resolve_destination_xdg_exact_match(monkeypatch):
759
+ """Test that exact XDG prefix paths resolve to XDG directory itself."""
760
+ from dotx.plan import resolve_destination
761
+
762
+ monkeypatch.setenv("XDG_CONFIG_HOME", "/custom/config")
763
+
764
+ default_root = Path("/home/user")
765
+ relative_path = Path(".config")
766
+
767
+ result = resolve_destination(relative_path, default_root, xdg_mode=True)
768
+
769
+ assert result == Path("/custom/config")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes