codetour-cli 0.1.0__py3-none-any.whl

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.
codetour_cli/config.py ADDED
@@ -0,0 +1,144 @@
1
+ """
2
+ Configuration management for CodeTour CLI.
3
+
4
+ Handles loading, merging, and saving configuration files.
5
+ """
6
+
7
+ import structlog
8
+ from pathlib import Path
9
+ from typing import Optional, Dict, Any
10
+ import yaml
11
+
12
+ log = structlog.get_logger()
13
+
14
+ # Default configuration
15
+ DEFAULT_CONFIG = {
16
+ "tours_dir": ".tours",
17
+ "migration": {
18
+ "add_ref": True,
19
+ "backup": True,
20
+ "threshold": 0.7,
21
+ "diff_algorithm": "histogram",
22
+ "interactive": False,
23
+ },
24
+ "display": {
25
+ "color": True,
26
+ "verbosity": "normal",
27
+ }
28
+ }
29
+
30
+ CONFIG_TEMPLATE = """# CodeTour CLI Configuration
31
+ # Generated: {date}
32
+
33
+ # Tours location
34
+ tours_dir: .tours
35
+
36
+ # Migration behavior
37
+ migration:
38
+ # Auto-inject 'ref' field after migration
39
+ add_ref: true
40
+
41
+ # Create backup files
42
+ backup: true
43
+
44
+ # Confidence threshold for auto-applying changes
45
+ threshold: 0.7
46
+
47
+ # Git diff algorithm (myers, minimal, patience, histogram)
48
+ diff_algorithm: histogram
49
+
50
+ # Interactive mode by default
51
+ interactive: false
52
+
53
+ # Display preferences
54
+ display:
55
+ # Use colors and emojis in output
56
+ color: true
57
+
58
+ # Verbosity level (quiet, normal, verbose)
59
+ verbosity: normal
60
+ """
61
+
62
+
63
+ def generate_config_file(path: Path) -> None:
64
+ """
65
+ Generate a default configuration file.
66
+
67
+ Args:
68
+ path: Path where to save the config file
69
+ """
70
+ from datetime import datetime
71
+
72
+ config_content = CONFIG_TEMPLATE.format(date=datetime.now().strftime("%Y-%m-%d"))
73
+
74
+ path.write_text(config_content)
75
+ log.info("generated_config", path=str(path))
76
+
77
+
78
+ def load_config(config_path: Optional[Path] = None) -> Dict[str, Any]:
79
+ """
80
+ Load configuration from file.
81
+
82
+ Args:
83
+ config_path: Path to config file (default: search for codetour.yaml)
84
+
85
+ Returns:
86
+ Configuration dictionary (merged with defaults)
87
+ """
88
+ config = DEFAULT_CONFIG.copy()
89
+
90
+ # Find config file if not specified
91
+ if config_path is None:
92
+ # Try .tours directory first (most discrete, project-specific)
93
+ tours_config = Path.cwd() / ".tours" / "codetour.yaml"
94
+ if tours_config.exists():
95
+ config_path = tours_config
96
+ else:
97
+ # Try current directory
98
+ config_path = Path.cwd() / "codetour.yaml"
99
+ if not config_path.exists():
100
+ # Try user config
101
+ user_config = Path.home() / ".config" / "codetour-cli" / "config.yaml"
102
+ if user_config.exists():
103
+ config_path = user_config
104
+ else:
105
+ # No config file found, use defaults
106
+ return config
107
+
108
+ if not config_path.exists():
109
+ return config
110
+
111
+ try:
112
+ with open(config_path) as f:
113
+ user_config = yaml.safe_load(f) or {}
114
+
115
+ # Merge with defaults (user config takes precedence)
116
+ config = merge_config(config, user_config)
117
+
118
+ log.info("loaded_config", path=str(config_path))
119
+ except Exception as e:
120
+ log.warning("failed_to_load_config", path=str(config_path), error=str(e))
121
+
122
+ return config
123
+
124
+
125
+ def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
126
+ """
127
+ Merge configuration dictionaries recursively.
128
+
129
+ Args:
130
+ base: Base configuration
131
+ override: Override configuration (takes precedence)
132
+
133
+ Returns:
134
+ Merged configuration
135
+ """
136
+ result = base.copy()
137
+
138
+ for key, value in override.items():
139
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
140
+ result[key] = merge_config(result[key], value)
141
+ else:
142
+ result[key] = value
143
+
144
+ return result
@@ -0,0 +1,197 @@
1
+ """
2
+ Description edit validation for review reports.
3
+
4
+ Provides safety checks for edited descriptions to catch copy-paste errors,
5
+ placeholder text, and suspicious changes.
6
+ """
7
+
8
+ import re
9
+ import difflib
10
+ from typing import Tuple, List
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass
15
+ class ValidationResult:
16
+ """Result of description validation."""
17
+ is_valid: bool # False if hard errors found
18
+ errors: List[str] # Tier 1: Hard errors (prevent apply)
19
+ warnings: List[str] # Tier 2: Warnings (prompt user, allow with --yes)
20
+ info: List[str] # Tier 3: Info (just log)
21
+ similarity: float # 0.0 to 1.0
22
+ len_ratio: float # edited_len / original_len
23
+
24
+
25
+ def calculate_similarity(text1: str, text2: str) -> float:
26
+ """
27
+ Calculate text similarity using SequenceMatcher.
28
+
29
+ Args:
30
+ text1: First text
31
+ text2: Second text
32
+
33
+ Returns:
34
+ Similarity ratio from 0.0 (completely different) to 1.0 (identical)
35
+ """
36
+ if not text1 and not text2:
37
+ return 1.0
38
+ if not text1 or not text2:
39
+ return 0.0
40
+
41
+ return difflib.SequenceMatcher(None, text1, text2).ratio()
42
+
43
+
44
+ def check_structure_preserved(original: str, edited: str) -> List[str]:
45
+ """
46
+ Check if structural markdown elements are preserved.
47
+
48
+ Args:
49
+ original: Original description
50
+ edited: Edited description
51
+
52
+ Returns:
53
+ List of warning messages about missing structural elements
54
+ """
55
+ warnings = []
56
+
57
+ # Code blocks
58
+ orig_code = original.count('```')
59
+ edit_code = edited.count('```')
60
+ if orig_code > 0 and edit_code == 0:
61
+ warnings.append("Original had code blocks, edited version doesn't")
62
+
63
+ # Bullet lists (significant number) - handle indented bullets
64
+ orig_lists = len(re.findall(r'\n\s*[-*] ', original))
65
+ edit_lists = len(re.findall(r'\n\s*[-*] ', edited))
66
+ if orig_lists > 3 and edit_lists == 0:
67
+ warnings.append("Original had bullet lists, edited version doesn't")
68
+
69
+ # Markdown links
70
+ orig_links = original.count('](')
71
+ edit_links = edited.count('](')
72
+ if orig_links > 0 and edit_links == 0:
73
+ warnings.append("Original had markdown links, edited version doesn't")
74
+
75
+ # Headers - handle indented headers
76
+ orig_headers = len(re.findall(r'\n\s*##+ ', original))
77
+ edit_headers = len(re.findall(r'\n\s*##+ ', edited))
78
+ if orig_headers > 0 and edit_headers == 0:
79
+ warnings.append("Original had section headers, edited version doesn't")
80
+
81
+ return warnings
82
+
83
+
84
+ def validate_edited_description(
85
+ edited_desc: str,
86
+ original_desc: str
87
+ ) -> ValidationResult:
88
+ """
89
+ Validate edited description for common errors and suspicious changes.
90
+
91
+ Implements 3-tier validation:
92
+ - Tier 1 (Errors): Hard failures that prevent apply
93
+ - Tier 2 (Warnings): Suspicious changes that prompt user
94
+ - Tier 3 (Info): Informational metrics (just logged)
95
+
96
+ Args:
97
+ edited_desc: User-edited description from review report
98
+ original_desc: Original tour step description
99
+
100
+ Returns:
101
+ ValidationResult with errors, warnings, and metrics
102
+ """
103
+ errors = []
104
+ warnings = []
105
+ info = []
106
+
107
+ # Calculate metrics
108
+ edited_clean = edited_desc.strip() if edited_desc else ""
109
+ original_clean = original_desc.strip() if original_desc else ""
110
+
111
+ similarity = calculate_similarity(original_clean, edited_clean)
112
+ len_ratio = len(edited_clean) / max(len(original_clean), 1)
113
+
114
+ # Tier 1: Hard errors (prevent apply)
115
+ if not edited_clean:
116
+ errors.append("Edited description is empty")
117
+
118
+ if "Edit the step description here" in edited_desc:
119
+ errors.append("Edited description contains placeholder text")
120
+
121
+ if "(Edit " in edited_desc and " above)" in edited_desc:
122
+ errors.append("Edited description contains template instructions")
123
+
124
+ if edited_clean and len(edited_clean) < 10:
125
+ errors.append(f"Edited description is too short ({len(edited_clean)} chars)")
126
+
127
+ # Tier 2: Warnings (prompt user, allow with --yes)
128
+ if similarity < 0.3:
129
+ warnings.append(
130
+ f"Description changed significantly ({similarity:.0%} similarity)\n"
131
+ f" Original: {len(original_clean)} chars, Edited: {len(edited_clean)} chars\n"
132
+ f" This might indicate a copy-paste error"
133
+ )
134
+
135
+ if len_ratio > 3.0:
136
+ warnings.append(
137
+ f"Edited description is 3x longer than original\n"
138
+ f" This might indicate accidental content inclusion"
139
+ )
140
+ elif len_ratio < 0.3 and len(original_clean) > 50:
141
+ warnings.append(
142
+ f"Edited description is much shorter than original\n"
143
+ f" This might indicate truncated content"
144
+ )
145
+
146
+ # Check structural elements
147
+ struct_warnings = check_structure_preserved(original_clean, edited_clean)
148
+ if struct_warnings:
149
+ warnings.append(
150
+ "Structural elements changed:\n " +
151
+ "\n ".join(struct_warnings)
152
+ )
153
+
154
+ # Tier 3: Info (moderate changes, just log)
155
+ if 0.3 <= similarity < 0.7:
156
+ info.append(f"Moderate change ({similarity:.0%} similarity)")
157
+
158
+ word_delta = len(edited_clean.split()) - len(original_clean.split())
159
+ if abs(word_delta) > 10:
160
+ info.append(f"Word count delta: {word_delta:+d}")
161
+
162
+ is_valid = len(errors) == 0
163
+
164
+ return ValidationResult(
165
+ is_valid=is_valid,
166
+ errors=errors,
167
+ warnings=warnings,
168
+ info=info,
169
+ similarity=similarity,
170
+ len_ratio=len_ratio
171
+ )
172
+
173
+
174
+ def format_validation_message(step_num: int, result: ValidationResult) -> str:
175
+ """
176
+ Format validation result as user-friendly message.
177
+
178
+ Args:
179
+ step_num: Step number being validated
180
+ result: ValidationResult from validate_edited_description
181
+
182
+ Returns:
183
+ Formatted message string for display
184
+ """
185
+ lines = []
186
+
187
+ if result.errors:
188
+ lines.append(f" ❌ Step {step_num}: Description edit failed validation:")
189
+ for error in result.errors:
190
+ lines.append(f" - {error}")
191
+
192
+ if result.warnings:
193
+ lines.append(f" ⚠️ Step {step_num}: Description edit warnings:")
194
+ for warning in result.warnings:
195
+ lines.append(f" {warning}")
196
+
197
+ return "\n".join(lines)
@@ -0,0 +1,91 @@
1
+ """
2
+ Tour source commit detection.
3
+
4
+ Determines which commit a tour should be migrated from.
5
+ """
6
+
7
+ import structlog
8
+ from pathlib import Path
9
+ from git import Repo, GitCommandError
10
+
11
+ from codetour_cli.tour.schema import Tour
12
+
13
+ log = structlog.get_logger()
14
+
15
+
16
+ def detect_source_commit(repo: Repo, tour_path: str | Path) -> tuple[str, float, str]:
17
+ """
18
+ Detect the commit where this tour was last valid.
19
+
20
+ Strategy:
21
+ 1. If tour has explicit 'ref' field → use it (user intent)
22
+ 2. If tour file is in git → last commit that touched it
23
+ 3. If file not committed → use HEAD (assume current)
24
+
25
+ Args:
26
+ repo: GitPython Repo object
27
+ tour_path: Path to .tour file (relative to repo root)
28
+
29
+ Returns:
30
+ tuple of (commit_sha, confidence, method)
31
+ - commit_sha: The detected source commit
32
+ - confidence: How confident we are (0.0-1.0)
33
+ - method: How we detected it ('explicit_ref', 'git_log', 'uncommitted')
34
+
35
+ Examples:
36
+ >>> detect_source_commit(repo, ".tours/main.tour")
37
+ ('abc123...', 0.95, 'git_log')
38
+ """
39
+ tour_path = Path(tour_path)
40
+
41
+ # Construct absolute path if relative (relative to repo root)
42
+ if not tour_path.is_absolute():
43
+ tour_path = Path(repo.working_dir) / tour_path
44
+
45
+ # Load tour to check for explicit ref field
46
+ try:
47
+ tour = Tour.from_file(tour_path)
48
+ except Exception as e:
49
+ log.error("failed_to_load_tour", path=str(tour_path), error=str(e))
50
+ # Fallback: assume current HEAD
51
+ return repo.head.commit.hexsha, 0.3, "load_failed"
52
+
53
+ # 1. Explicit ref takes priority (user intent)
54
+ if tour.ref:
55
+ log.info(
56
+ "using_explicit_ref",
57
+ path=str(tour_path),
58
+ ref=tour.ref[:8] if len(tour.ref) >= 8 else tour.ref
59
+ )
60
+ return tour.ref, 1.0, "explicit_ref"
61
+
62
+ # 2. Most recent commit that touched this file
63
+ try:
64
+ # Get the most recent commit for this file
65
+ # --follow tracks through renames
66
+ commits = list(repo.iter_commits(paths=str(tour_path), max_count=1))
67
+
68
+ if commits:
69
+ last_commit = commits[0]
70
+ log.info(
71
+ "detected_via_git_log",
72
+ path=str(tour_path),
73
+ commit=last_commit.hexsha[:8],
74
+ date=last_commit.committed_datetime.isoformat()
75
+ )
76
+ return last_commit.hexsha, 0.95, "git_log"
77
+
78
+ except GitCommandError as e:
79
+ log.warning(
80
+ "git_log_failed",
81
+ path=str(tour_path),
82
+ error=str(e)
83
+ )
84
+
85
+ # 3. File not in git (new/uncommitted)
86
+ log.warning(
87
+ "tour_not_in_git",
88
+ path=str(tour_path),
89
+ fallback="HEAD"
90
+ )
91
+ return repo.head.commit.hexsha, 0.5, "uncommitted"
@@ -0,0 +1,226 @@
1
+ """
2
+ Tour discovery and repository scanning.
3
+
4
+ Auto-discovers tours in the current repository.
5
+ """
6
+
7
+ import structlog
8
+ from pathlib import Path
9
+ from typing import List, Optional, Tuple
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+
13
+ from git import Repo, InvalidGitRepositoryError, GitCommandError
14
+
15
+ from codetour_cli.tour.schema import Tour
16
+ from codetour_cli.detection import detect_source_commit
17
+
18
+ log = structlog.get_logger()
19
+
20
+
21
+ @dataclass
22
+ class TourInfo:
23
+ """
24
+ Information about a discovered tour.
25
+ """
26
+ path: Path # Absolute path to tour file
27
+ tour: Tour # Loaded tour object
28
+ source_commit: str # Detected source commit
29
+ source_confidence: float # Confidence in source detection
30
+ source_method: str # How source was detected
31
+ commits_behind: int # Number of commits since source
32
+ last_modified: datetime # When tour file was last modified in git
33
+
34
+
35
+ @dataclass
36
+ class RepositoryInfo:
37
+ """
38
+ Information about the repository containing tours.
39
+ """
40
+ repo_path: Path # Repository root
41
+ tours_dir: Path # Tours directory (.tours/)
42
+ tours: List[TourInfo] # Discovered tours
43
+
44
+
45
+ def find_repo_root(start_path: Optional[Path] = None) -> Path:
46
+ """
47
+ Find the git repository root, starting from start_path.
48
+
49
+ Args:
50
+ start_path: Directory to start searching from (default: current directory)
51
+
52
+ Returns:
53
+ Path to repository root
54
+
55
+ Raises:
56
+ InvalidGitRepositoryError: If not in a git repository
57
+ """
58
+ start_path = start_path or Path.cwd()
59
+
60
+ try:
61
+ repo = Repo(start_path, search_parent_directories=True)
62
+ repo_root = Path(repo.working_dir)
63
+ log.debug("found_repo_root", path=str(repo_root))
64
+ return repo_root
65
+ except InvalidGitRepositoryError as e:
66
+ log.error("not_a_git_repo", start_path=str(start_path))
67
+ raise InvalidGitRepositoryError(
68
+ f"Not a git repository (or any parent up to mount point)\n"
69
+ f"CodeTour CLI requires git to track tour drift.\n"
70
+ f"Initialize git with: git init"
71
+ ) from e
72
+
73
+
74
+ def find_tours_directory(repo_root: Path) -> Optional[Path]:
75
+ """
76
+ Find the .tours directory in the repository.
77
+
78
+ Args:
79
+ repo_root: Repository root path
80
+
81
+ Returns:
82
+ Path to tours directory, or None if not found
83
+ """
84
+ tours_dir = repo_root / ".tours"
85
+
86
+ if tours_dir.exists() and tours_dir.is_dir():
87
+ log.debug("found_tours_directory", path=str(tours_dir))
88
+ return tours_dir
89
+
90
+ log.warning("tours_directory_not_found", repo_root=str(repo_root))
91
+ return None
92
+
93
+
94
+ def scan_tours(tours_dir: Path) -> List[Path]:
95
+ """
96
+ Scan for .tour files in the tours directory.
97
+
98
+ Args:
99
+ tours_dir: Path to tours directory
100
+
101
+ Returns:
102
+ List of paths to tour files
103
+ """
104
+ tour_files = sorted(tours_dir.glob("*.tour"))
105
+ log.info("scanned_tours", num_tours=len(tour_files), tours_dir=str(tours_dir))
106
+ return tour_files
107
+
108
+
109
+ def load_tour_info(repo: Repo, tour_path: Path) -> Optional[TourInfo]:
110
+ """
111
+ Load full information about a tour.
112
+
113
+ Args:
114
+ repo: GitPython Repo object
115
+ tour_path: Path to tour file
116
+
117
+ Returns:
118
+ TourInfo object, or None if tour couldn't be loaded
119
+ """
120
+ try:
121
+ # Load tour
122
+ tour = Tour.from_file(tour_path)
123
+
124
+ # Detect source commit
125
+ tour_path_rel = tour_path.relative_to(repo.working_dir)
126
+ source_commit, confidence, method = detect_source_commit(repo, tour_path_rel)
127
+
128
+ # Count commits since source
129
+ try:
130
+ commits = list(repo.iter_commits(f"{source_commit}..HEAD"))
131
+ commits_behind = len(commits)
132
+ except GitCommandError:
133
+ # Source commit might not be reachable
134
+ commits_behind = 0
135
+
136
+ # Get last modified time from git
137
+ try:
138
+ last_commit = list(repo.iter_commits(paths=str(tour_path_rel), max_count=1))
139
+ if last_commit:
140
+ last_modified = last_commit[0].committed_datetime
141
+ else:
142
+ # File not in git, use filesystem mtime
143
+ last_modified = datetime.fromtimestamp(tour_path.stat().st_mtime)
144
+ except (GitCommandError, OSError):
145
+ last_modified = datetime.now()
146
+
147
+ return TourInfo(
148
+ path=tour_path,
149
+ tour=tour,
150
+ source_commit=source_commit,
151
+ source_confidence=confidence,
152
+ source_method=method,
153
+ commits_behind=commits_behind,
154
+ last_modified=last_modified
155
+ )
156
+
157
+ except Exception as e:
158
+ log.error("failed_to_load_tour", path=str(tour_path), error=str(e))
159
+ return None
160
+
161
+
162
+ def discover_repository(start_path: Optional[Path] = None) -> RepositoryInfo:
163
+ """
164
+ Discover repository and all tours within it.
165
+
166
+ Args:
167
+ start_path: Directory to start searching from (default: current directory)
168
+
169
+ Returns:
170
+ RepositoryInfo with all discovered tours
171
+
172
+ Raises:
173
+ InvalidGitRepositoryError: If not in a git repository
174
+ ValueError: If no .tours directory found
175
+ """
176
+ # Find repo root
177
+ repo_root = find_repo_root(start_path)
178
+ repo = Repo(repo_root)
179
+
180
+ # Find tours directory
181
+ tours_dir = find_tours_directory(repo_root)
182
+ if tours_dir is None:
183
+ raise ValueError(
184
+ f"No .tours directory found in {repo_root}\n"
185
+ f"Create it with: mkdir .tours\n"
186
+ f"Or run: codetour init"
187
+ )
188
+
189
+ # Scan for tours
190
+ tour_paths = scan_tours(tours_dir)
191
+ if not tour_paths:
192
+ log.warning("no_tours_found", tours_dir=str(tours_dir))
193
+
194
+ # Load tour information
195
+ tours = []
196
+ for tour_path in tour_paths:
197
+ tour_info = load_tour_info(repo, tour_path)
198
+ if tour_info:
199
+ tours.append(tour_info)
200
+
201
+ log.info(
202
+ "discovered_repository",
203
+ repo_root=str(repo_root),
204
+ tours_dir=str(tours_dir),
205
+ num_tours=len(tours)
206
+ )
207
+
208
+ return RepositoryInfo(
209
+ repo_path=repo_root,
210
+ tours_dir=tours_dir,
211
+ tours=tours
212
+ )
213
+
214
+
215
+ def is_tour_up_to_date(tour_info: TourInfo, threshold: int = 0) -> bool:
216
+ """
217
+ Check if a tour is up to date.
218
+
219
+ Args:
220
+ tour_info: Tour information
221
+ threshold: Maximum commits behind to be considered up to date
222
+
223
+ Returns:
224
+ True if tour is up to date
225
+ """
226
+ return tour_info.commits_behind <= threshold