dotx 3.2.2__tar.gz → 3.3.1__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 (41) hide show
  1. {dotx-3.2.2/src/dotx.egg-info → dotx-3.3.1}/PKG-INFO +1 -1
  2. {dotx-3.2.2 → dotx-3.3.1}/pyproject.toml +1 -1
  3. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/cli.py +13 -1
  4. dotx-3.3.1/src/dotx/commands/uninstall_cmd.py +155 -0
  5. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/install.py +11 -4
  6. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/options.py +28 -1
  7. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/plan.py +40 -3
  8. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/uninstall.py +14 -6
  9. {dotx-3.2.2 → dotx-3.3.1/src/dotx.egg-info}/PKG-INFO +1 -1
  10. {dotx-3.2.2 → dotx-3.3.1}/tests/test_cli.py +156 -0
  11. {dotx-3.2.2 → dotx-3.3.1}/tests/test_install.py +88 -0
  12. dotx-3.2.2/src/dotx/commands/uninstall_cmd.py +0 -75
  13. {dotx-3.2.2 → dotx-3.3.1}/LICENSE +0 -0
  14. {dotx-3.2.2 → dotx-3.3.1}/MANIFEST.in +0 -0
  15. {dotx-3.2.2 → dotx-3.3.1}/README.md +0 -0
  16. {dotx-3.2.2 → dotx-3.3.1}/setup.cfg +0 -0
  17. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/__init__.py +0 -0
  18. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/always-create +0 -0
  19. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/commands/__init__.py +0 -0
  20. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/commands/database.py +0 -0
  21. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/commands/install_cmd.py +0 -0
  22. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/commands/path_cmd.py +0 -0
  23. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/commands/progress.py +0 -0
  24. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/database.py +0 -0
  25. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/dotxignore +0 -0
  26. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/hierarchy.py +0 -0
  27. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/ignore.py +0 -0
  28. {dotx-3.2.2 → dotx-3.3.1}/src/dotx/installed-schema.sql +0 -0
  29. {dotx-3.2.2 → dotx-3.3.1}/src/dotx.egg-info/SOURCES.txt +0 -0
  30. {dotx-3.2.2 → dotx-3.3.1}/src/dotx.egg-info/dependency_links.txt +0 -0
  31. {dotx-3.2.2 → dotx-3.3.1}/src/dotx.egg-info/entry_points.txt +0 -0
  32. {dotx-3.2.2 → dotx-3.3.1}/src/dotx.egg-info/requires.txt +0 -0
  33. {dotx-3.2.2 → dotx-3.3.1}/src/dotx.egg-info/top_level.txt +0 -0
  34. {dotx-3.2.2 → dotx-3.3.1}/tests/test_always_create.py +0 -0
  35. {dotx-3.2.2 → dotx-3.3.1}/tests/test_cli_database.py +0 -0
  36. {dotx-3.2.2 → dotx-3.3.1}/tests/test_ignore.py +0 -0
  37. {dotx-3.2.2 → dotx-3.3.1}/tests/test_ignore_rules.py +0 -0
  38. {dotx-3.2.2 → dotx-3.3.1}/tests/test_options.py +0 -0
  39. {dotx-3.2.2 → dotx-3.3.1}/tests/test_path_which.py +0 -0
  40. {dotx-3.2.2 → dotx-3.3.1}/tests/test_plan.py +0 -0
  41. {dotx-3.2.2 → dotx-3.3.1}/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.1
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.1"
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)
@@ -0,0 +1,155 @@
1
+ """Uninstall command for dotx CLI."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from loguru import logger
8
+ from rich.console import Console
9
+
10
+ from dotx.commands.progress import execute_plans_with_progress
11
+ from dotx.database import InstallationDB
12
+ from dotx.options import is_dry_run, is_verbose_mode
13
+ from dotx.plan import Action, Plan, extract_plan, log_extracted_plan
14
+ from dotx.uninstall import plan_uninstall
15
+
16
+
17
+ def _uninstall_from_database(
18
+ package_path: Path,
19
+ db: InstallationDB,
20
+ console: Console,
21
+ dry_run: bool,
22
+ verbose: bool,
23
+ ) -> int:
24
+ """
25
+ Uninstall a package using database records when source directory is missing.
26
+
27
+ Returns the number of symlinks removed.
28
+ """
29
+ package_root = package_path.parent
30
+ package_name = package_path.name
31
+
32
+ installations = db.get_installations(package_root, package_name)
33
+
34
+ if not installations:
35
+ console.print(f"[yellow]No installations found for {package_name}[/yellow]")
36
+ return 0
37
+
38
+ removed_count = 0
39
+
40
+ for entry in installations:
41
+ target_path = Path(entry["target_path"])
42
+
43
+ if dry_run:
44
+ if target_path.is_symlink():
45
+ console.print(f" rm {target_path}")
46
+ removed_count += 1
47
+ elif verbose:
48
+ console.print(f" [dim]skip (not a symlink): {target_path}[/dim]")
49
+ else:
50
+ if target_path.is_symlink():
51
+ try:
52
+ target_path.unlink()
53
+ db.remove_installation(target_path)
54
+ removed_count += 1
55
+ if verbose:
56
+ console.print(f" Removed: {target_path}")
57
+ except OSError as e:
58
+ logger.warning(f"Failed to remove {target_path}: {e}")
59
+ else:
60
+ # Not a symlink - just remove from database
61
+ db.remove_installation(target_path)
62
+ if verbose:
63
+ console.print(f" [dim]Cleaned db entry (not a symlink): {target_path}[/dim]")
64
+
65
+ return removed_count
66
+
67
+
68
+ def register_command(app: typer.Typer):
69
+ """Register the uninstall command with the Typer app."""
70
+
71
+ @app.command()
72
+ def uninstall(
73
+ ctx: typer.Context,
74
+ sources: Annotated[
75
+ list[Path],
76
+ typer.Argument(
77
+ help="Source package directories to uninstall (can be deleted)",
78
+ ),
79
+ ],
80
+ ):
81
+ """
82
+ Uninstall source packages from target directory.
83
+
84
+ If the source package directory still exists, uninstalls by scanning it.
85
+ If the source has been deleted, uninstalls using database records.
86
+ """
87
+ logger.info("uninstall starting")
88
+ console = Console()
89
+ verbose = is_verbose_mode(ctx)
90
+ dry_run = is_dry_run(ctx)
91
+
92
+ # Get target from options
93
+ target_path = Path(ctx.obj.get("TARGET", Path.home())) if ctx.obj else Path.home()
94
+
95
+ if not sources:
96
+ logger.info("uninstall finished (no sources)")
97
+ return
98
+
99
+ # Partition sources using set math
100
+ sources_set = set(sources)
101
+ existing_sources = {s for s in sources_set if s.exists() and s.is_dir()}
102
+ missing_sources = sources_set - existing_sources
103
+
104
+ total_removed = 0
105
+
106
+ # Handle existing sources with plan-based uninstall
107
+ if existing_sources:
108
+ plans: list[tuple[Path, Plan]] = []
109
+ for source_package in existing_sources:
110
+ plan: Plan = plan_uninstall(source_package, target_path)
111
+ log_extracted_plan(
112
+ plan,
113
+ description=f"Actual plan to uninstall {source_package}",
114
+ actions_to_extract={Action.UNLINK},
115
+ )
116
+ plans.append((source_package, plan))
117
+
118
+ with InstallationDB() as db:
119
+ execute_plans_with_progress(
120
+ plans,
121
+ target_path,
122
+ {Action.UNLINK},
123
+ "Uninstalling",
124
+ console,
125
+ verbose,
126
+ db,
127
+ )
128
+
129
+ total_removed += sum(
130
+ len(extract_plan(plan, {Action.UNLINK}))
131
+ for _, plan in plans
132
+ )
133
+
134
+ # Handle missing sources with database-based uninstall
135
+ if missing_sources:
136
+ if dry_run:
137
+ console.print("\n[yellow][DRY RUN] Would execute the equivalent of:[/yellow]")
138
+
139
+ with InstallationDB() as db:
140
+ for source_package in missing_sources:
141
+ if verbose or dry_run:
142
+ console.print(f"\n[cyan]Uninstalling {source_package.name} (source deleted)...[/cyan]")
143
+
144
+ removed = _uninstall_from_database(
145
+ source_package, db, console, dry_run, verbose
146
+ )
147
+ total_removed += removed
148
+
149
+ # Show summary
150
+ if dry_run:
151
+ console.print(f"\n[yellow][DRY RUN] Would remove {total_removed} symlink(s) from {len(sources)} package(s)[/yellow]")
152
+ else:
153
+ console.print(f"\n[green]✓ Removed {total_removed} symlink(s) from {len(sources)} package(s)[/green]")
154
+
155
+ logger.info("uninstall finished")
@@ -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.1
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,159 @@ 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"
392
+
393
+
394
+ def test_cli_uninstall_with_deleted_source(tmp_path, isolated_db):
395
+ """Test that uninstall works even when source package has been deleted."""
396
+ import shutil
397
+
398
+ source = tmp_path / "source"
399
+ source.mkdir()
400
+ target = tmp_path / "target"
401
+ target.mkdir()
402
+
403
+ # Create source files
404
+ (source / "file1.txt").write_text("content1")
405
+ (source / "file2.txt").write_text("content2")
406
+
407
+ runner = CliRunner()
408
+
409
+ # Install the package
410
+ result = runner.invoke(app, [f"--target={target}", "install", str(source)])
411
+ assert result.exit_code == 0, f"Install failed: {result.output}"
412
+
413
+ # Verify files are installed
414
+ assert (target / "file1.txt").is_symlink()
415
+ assert (target / "file2.txt").is_symlink()
416
+
417
+ # Delete the source package
418
+ shutil.rmtree(source)
419
+ assert not source.exists()
420
+
421
+ # Uninstall should still work using database (use --verbose to see "source deleted" message)
422
+ result = runner.invoke(app, [f"--target={target}", "--verbose", "uninstall", str(source)])
423
+ assert result.exit_code == 0, f"Uninstall failed: {result.output}"
424
+ assert "source deleted" in result.output.lower()
425
+
426
+ # Symlinks should be removed (not just broken)
427
+ assert not (target / "file1.txt").is_symlink()
428
+ assert not (target / "file2.txt").is_symlink()
429
+
430
+
431
+ def test_cli_uninstall_deleted_source_dry_run(tmp_path, isolated_db):
432
+ """Test dry-run uninstall with deleted source shows correct output."""
433
+ import shutil
434
+
435
+ source = tmp_path / "source"
436
+ source.mkdir()
437
+ target = tmp_path / "target"
438
+ target.mkdir()
439
+
440
+ # Create and install
441
+ (source / "file1.txt").write_text("content")
442
+
443
+ runner = CliRunner()
444
+ result = runner.invoke(app, [f"--target={target}", "install", str(source)])
445
+ assert result.exit_code == 0
446
+
447
+ # Delete source
448
+ shutil.rmtree(source)
449
+
450
+ # Dry-run uninstall
451
+ result = runner.invoke(app, [f"--target={target}", "--dry-run", "uninstall", str(source)])
452
+ assert result.exit_code == 0
453
+ assert "[DRY RUN]" in result.output
454
+ assert "rm" in result.output
455
+
456
+ # Symlink should still exist after dry-run (even though it's broken)
457
+ assert (target / "file1.txt").is_symlink()
@@ -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")
@@ -1,75 +0,0 @@
1
- """Uninstall command for dotx CLI."""
2
-
3
- from pathlib import Path
4
- from typing import Annotated
5
-
6
- import typer
7
- from loguru import logger
8
- from rich.console import Console
9
-
10
- from dotx.commands.progress import execute_plans_with_progress
11
- from dotx.database import InstallationDB
12
- from dotx.options import is_dry_run, is_verbose_mode
13
- from dotx.plan import Action, Plan, extract_plan, log_extracted_plan
14
- from dotx.uninstall import plan_uninstall
15
-
16
-
17
- def register_command(app: typer.Typer):
18
- """Register the uninstall command with the Typer app."""
19
-
20
- @app.command()
21
- def uninstall(
22
- ctx: typer.Context,
23
- sources: Annotated[
24
- list[Path],
25
- typer.Argument(
26
- help="Source package directories to uninstall",
27
- exists=True,
28
- file_okay=False,
29
- dir_okay=True,
30
- readable=True,
31
- ),
32
- ],
33
- ):
34
- """Uninstall source packages from target directory."""
35
- logger.info("uninstall starting")
36
- console = Console()
37
- verbose = is_verbose_mode(ctx)
38
-
39
- # Get target from options
40
- target_path = Path(ctx.obj.get("TARGET", Path.home())) if ctx.obj else Path.home()
41
-
42
- if sources:
43
- plans: list[tuple[Path, Plan]] = []
44
- for source_package in sources:
45
- plan: Plan = plan_uninstall(source_package, target_path)
46
- log_extracted_plan(
47
- plan,
48
- description=f"Actual plan to uninstall {source_package}",
49
- actions_to_extract={Action.UNLINK},
50
- )
51
- plans.append((source_package, plan))
52
-
53
- # Open database and execute all plans with progress
54
- with InstallationDB() as db:
55
- execute_plans_with_progress(
56
- plans,
57
- target_path,
58
- {Action.UNLINK},
59
- "Uninstalling",
60
- console,
61
- verbose,
62
- db,
63
- )
64
-
65
- # Show summary
66
- total_removed = sum(
67
- len(extract_plan(plan, {Action.UNLINK}))
68
- for _, plan in plans
69
- )
70
- if is_dry_run(ctx):
71
- console.print(f"\n[yellow][DRY RUN] Would remove {total_removed} symlink(s) from {len(sources)} package(s)[/yellow]")
72
- else:
73
- console.print(f"\n[green]✓ Removed {total_removed} symlink(s) from {len(sources)} package(s)[/green]")
74
-
75
- logger.info("uninstall finished")
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