Skip to content

Commit 022b545

Browse files
authored
feat: add search command to find structures by keyword (#134)
1 parent bae4780 commit 022b545

4 files changed

Lines changed: 247 additions & 0 deletions

File tree

WARP.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ docs(api): update template variables documentation
151151
7. Link related issues: "Closes #123"
152152
8. Request review from maintainers
153153

154+
### Warp Agent Behavior
155+
156+
- Do NOT include Warp conversation links, plan links, or any Warp-specific references in PR descriptions, commit messages, or any other user-visible content.
157+
- Do NOT add co-author lines referencing Warp or `oz-agent@warp.dev` to commit messages.
158+
154159
## 🧪 Testing Guidelines
155160

156161
### Test Structure

structkit/commands/search.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import os
2+
import yaml
3+
4+
from structkit.commands import Command
5+
6+
7+
class SearchCommand(Command):
8+
"""Search available structures by keyword (matches name and description)."""
9+
10+
def __init__(self, parser):
11+
super().__init__(parser)
12+
parser.description = "Search available structures by keyword"
13+
parser.add_argument(
14+
'query',
15+
type=str,
16+
help='Search term to match against structure names and descriptions',
17+
)
18+
parser.add_argument(
19+
'-s', '--structures-path',
20+
type=str,
21+
help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)',
22+
default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None),
23+
)
24+
parser.add_argument(
25+
'--names-only',
26+
action='store_true',
27+
help='Print only matching structure names, one per line (for scripting)',
28+
)
29+
parser.set_defaults(func=self.execute)
30+
31+
def execute(self, args):
32+
self.logger.info(f"Searching structures for '{args.query}'")
33+
self._search_structures(args)
34+
35+
def _search_structures(self, args):
36+
this_file = os.path.dirname(os.path.realpath(__file__))
37+
contribs_path = os.path.join(this_file, '..', 'contribs')
38+
39+
if args.structures_path:
40+
paths_to_search = [(args.structures_path, False), (contribs_path, True)]
41+
else:
42+
paths_to_search = [(contribs_path, True)]
43+
44+
query = args.query.lower()
45+
matches = []
46+
47+
for path, is_contribs in paths_to_search:
48+
for root, _, files in os.walk(path):
49+
for file in files:
50+
if not file.endswith('.yaml'):
51+
continue
52+
file_path = os.path.join(root, file)
53+
rel_path = os.path.relpath(file_path, path)
54+
name = rel_path[:-5] # strip .yaml
55+
56+
description = ''
57+
try:
58+
with open(file_path, 'r') as f:
59+
config = yaml.safe_load(f) or {}
60+
description = config.get('description', '') or ''
61+
except Exception:
62+
pass
63+
64+
if query in name.lower() or query in description.lower():
65+
is_custom = not is_contribs
66+
matches.append((name, description, is_custom))
67+
68+
matches.sort(key=lambda x: x[0])
69+
70+
if args.names_only:
71+
for name, _, _ in matches:
72+
print(name)
73+
return
74+
75+
if not matches:
76+
print(f"No structures found matching '{args.query}'")
77+
return
78+
79+
print(f"🔍 Search results for '{args.query}'\n")
80+
for name, description, is_custom in matches:
81+
prefix = '+ ' if is_custom else ' '
82+
desc_str = f" — {description}" if description else ''
83+
print(f" {prefix}{name}{desc_str}")
84+
85+
print("\nUse 'structkit generate' to generate a structure")
86+
print("Note: Structures with '+' sign are custom structures")

structkit/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from structkit.commands.info import InfoCommand
88
from structkit.commands.validate import ValidateCommand
99
from structkit.commands.list import ListCommand
10+
from structkit.commands.search import SearchCommand
1011
from structkit.commands.generate_schema import GenerateSchemaCommand
1112
from structkit.commands.mcp import MCPCommand
1213
from structkit.logging_config import configure_logging
@@ -34,6 +35,7 @@ def get_parser():
3435
ValidateCommand(subparsers.add_parser('validate', help='Validate the YAML configuration file'))
3536
GenerateCommand(subparsers.add_parser('generate', help='Generate the project structure'))
3637
ListCommand(subparsers.add_parser('list', help='List available structures'))
38+
SearchCommand(subparsers.add_parser('search', help='Search available structures by keyword'))
3739
GenerateSchemaCommand(subparsers.add_parser('generate-schema', help='Generate JSON schema for available structures'))
3840
MCPCommand(subparsers.add_parser('mcp', help='MCP (Model Context Protocol) support'))
3941

tests/test_search_command.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import argparse
2+
import os
3+
import pytest
4+
from unittest.mock import patch, mock_open, MagicMock
5+
6+
from structkit.commands.search import SearchCommand
7+
8+
9+
@pytest.fixture
10+
def parser():
11+
return argparse.ArgumentParser()
12+
13+
14+
def _make_args(parser, query, structures_path=None, names_only=False):
15+
cmd = SearchCommand(parser)
16+
argv = [query]
17+
if structures_path:
18+
argv += ['-s', structures_path]
19+
if names_only:
20+
argv.append('--names-only')
21+
args = parser.parse_args(argv)
22+
return cmd, args
23+
24+
25+
def test_search_match_by_name(parser):
26+
"""Structures whose name matches the query are returned."""
27+
cmd, args = _make_args(parser, 'docker')
28+
29+
yaml_content = b'files: []'
30+
walk_data = [('/fake/contribs', [], ['docker-files.yaml', 'helm-chart.yaml'])]
31+
32+
def fake_open(path, *a, **kw):
33+
return mock_open(read_data=yaml_content)()
34+
35+
with patch('os.path.dirname', return_value='/fake/commands'), \
36+
patch('os.path.realpath', return_value='/fake/commands'), \
37+
patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \
38+
patch('os.walk', return_value=walk_data), \
39+
patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \
40+
patch('builtins.open', side_effect=fake_open), \
41+
patch('yaml.safe_load', return_value={}), \
42+
patch('builtins.print') as mock_print:
43+
cmd._search_structures(args)
44+
45+
printed = ' '.join(str(c) for c in mock_print.call_args_list)
46+
assert 'docker-files' in printed
47+
assert 'helm-chart' not in printed
48+
49+
50+
def test_search_match_by_description(parser):
51+
"""Structures whose description matches the query are returned."""
52+
cmd, args = _make_args(parser, 'kubernetes')
53+
54+
configs = {
55+
'helm-chart.yaml': {'description': 'Deploy apps to kubernetes clusters'},
56+
'docker-files.yaml': {},
57+
}
58+
walk_data = [('/fake/contribs', [], ['helm-chart.yaml', 'docker-files.yaml'])]
59+
60+
# Track which file is currently being opened so yaml.safe_load can return the right config
61+
current_path = [None]
62+
63+
def fake_open(path, *a, **kw):
64+
current_path[0] = path
65+
return mock_open(read_data=b'')()
66+
67+
def fake_yaml_load(f):
68+
for basename, config in configs.items():
69+
if current_path[0] and current_path[0].endswith(basename):
70+
return config
71+
return {}
72+
73+
with patch('os.path.dirname', return_value='/fake/commands'), \
74+
patch('os.path.realpath', return_value='/fake/commands'), \
75+
patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \
76+
patch('os.walk', return_value=walk_data), \
77+
patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \
78+
patch('builtins.open', side_effect=fake_open), \
79+
patch('yaml.safe_load', side_effect=fake_yaml_load), \
80+
patch('builtins.print') as mock_print:
81+
cmd._search_structures(args)
82+
83+
printed = ' '.join(str(c) for c in mock_print.call_args_list)
84+
assert 'helm-chart' in printed
85+
assert 'docker-files' not in printed
86+
87+
88+
def test_search_no_results(parser):
89+
"""No-match query prints an appropriate message."""
90+
cmd, args = _make_args(parser, 'xyznotfound')
91+
92+
walk_data = [('/fake/contribs', [], ['docker-files.yaml'])]
93+
94+
with patch('os.path.dirname', return_value='/fake/commands'), \
95+
patch('os.path.realpath', return_value='/fake/commands'), \
96+
patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \
97+
patch('os.walk', return_value=walk_data), \
98+
patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \
99+
patch('builtins.open', mock_open(read_data=b'{}')), \
100+
patch('yaml.safe_load', return_value={}), \
101+
patch('builtins.print') as mock_print:
102+
cmd._search_structures(args)
103+
104+
printed = ' '.join(str(c) for c in mock_print.call_args_list)
105+
assert 'No structures found' in printed
106+
107+
108+
def test_search_names_only(parser):
109+
"""--names-only prints bare names without decoration."""
110+
cmd, args = _make_args(parser, 'docker', names_only=True)
111+
112+
walk_data = [('/fake/contribs', [], ['docker-files.yaml', 'docker-compose.yaml'])]
113+
114+
with patch('os.path.dirname', return_value='/fake/commands'), \
115+
patch('os.path.realpath', return_value='/fake/commands'), \
116+
patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \
117+
patch('os.walk', return_value=walk_data), \
118+
patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \
119+
patch('builtins.open', mock_open(read_data=b'{}')), \
120+
patch('yaml.safe_load', return_value={}), \
121+
patch('builtins.print') as mock_print:
122+
cmd._search_structures(args)
123+
124+
calls = [str(c.args[0]) for c in mock_print.call_args_list]
125+
# Should only print plain names, no emoji or bullet prefix
126+
assert all('🔍' not in c and ' - ' not in c for c in calls)
127+
assert any('docker-compose' in c for c in calls)
128+
assert any('docker-files' in c for c in calls)
129+
130+
131+
def test_search_custom_structures_path(parser, tmp_path):
132+
"""Custom --structures-path structures appear with '+' marker."""
133+
custom_yaml = tmp_path / 'my-custom.yaml'
134+
custom_yaml.write_text('description: my custom structure\n')
135+
136+
cmd = SearchCommand(parser)
137+
args = parser.parse_args(['custom', '-s', str(tmp_path)])
138+
139+
with patch('builtins.print') as mock_print:
140+
cmd._search_structures(args)
141+
142+
printed = ' '.join(str(c) for c in mock_print.call_args_list)
143+
assert 'my-custom' in printed
144+
assert '+' in printed
145+
146+
147+
def test_search_command_registered_in_main():
148+
"""The search subcommand is registered in the main parser."""
149+
from structkit.main import get_parser
150+
parser = get_parser()
151+
# If 'search' is not registered, parse_args will error
152+
args = parser.parse_args(['search', 'docker'])
153+
assert hasattr(args, 'func')
154+
assert args.query == 'docker'

0 commit comments

Comments
 (0)