Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 25 additions & 24 deletions src/codegraphcontext/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def _configure_library_loggers():
# `console` stays for interactive prompts and diagnostics. Argument validation
# errors inside those commands remain on the stderr console.
analyze_result_console = Console()
find_result_console = Console()

# Configure basic logging for the application. Default to WARNING so CLI
# output stays clean; the root --debug flag switches this to DEBUG.
Expand Down Expand Up @@ -2221,7 +2222,7 @@ def find_by_name(
for r in results: r['type'] = 'File'

if not results:
console.print(f"[yellow]No code elements found with name '{name}'[/yellow]")
find_result_console.print(f"[yellow]No code elements found with name '{name}'[/yellow]")
return

# Check if visual mode is enabled
Expand All @@ -2245,8 +2246,8 @@ def find_by_name(
location_str
)

console.print(f"[cyan]Found {len(results)} matches for '{name}':[/cyan]")
console.print(table)
find_result_console.print(f"[cyan]Found {len(results)} matches for '{name}':[/cyan]")
find_result_console.print(table)
finally:
db_manager.close_driver()

Expand Down Expand Up @@ -2309,7 +2310,7 @@ def find_by_pattern(
results = [dict(record) for record in result]

if not results:
console.print(f"[yellow]No matches found for pattern '{pattern}'[/yellow]")
find_result_console.print(f"[yellow]No matches found for pattern '{pattern}'[/yellow]")
return

# Check if visual mode is enabled
Expand All @@ -2318,7 +2319,7 @@ def find_by_pattern(
return

if not case_sensitive and any(c in pattern for c in "*?["):
console.print("[yellow]Note: Wildcards/Regex are not fully supported in this mode. Performing substring search.[/yellow]")
find_result_console.print("[yellow]Note: Wildcards/Regex are not fully supported in this mode. Performing substring search.[/yellow]")

table = Table(show_header=True, header_style="bold magenta", box=box.ROUNDED)
table.add_column("Name", style="cyan")
Expand All @@ -2338,8 +2339,8 @@ def find_by_pattern(
"📦 Dependency" if res.get('is_dependency') else "📝 Project"
)

console.print(f"[cyan]Found {len(results)} matches for pattern '{pattern}':[/cyan]")
console.print(table)
find_result_console.print(f"[cyan]Found {len(results)} matches for pattern '{pattern}':[/cyan]")
find_result_console.print(table)
finally:
db_manager.close_driver()

Expand Down Expand Up @@ -2369,7 +2370,7 @@ def find_by_type(
results = code_finder.find_by_type(element_type, limit)

if not results:
console.print(f"[yellow]No elements found of type '{element_type}'[/yellow]")
find_result_console.print(f"[yellow]No elements found of type '{element_type}'[/yellow]")
return

# Add type to results for visualization
Expand Down Expand Up @@ -2397,8 +2398,8 @@ def find_by_type(
"📦 Dependency" if res.get('is_dependency') else "📝 Project"
)

console.print(f"[cyan]Found {len(results)} {element_type}s:[/cyan]")
console.print(table)
find_result_console.print(f"[cyan]Found {len(results)} {element_type}s:[/cyan]")
find_result_console.print(table)
finally:
db_manager.close_driver()

Expand All @@ -2424,7 +2425,7 @@ def find_by_variable(
results = code_finder.find_by_variable_name(name)

if not results:
console.print(f"[yellow]No variables found with name '{name}'[/yellow]")
find_result_console.print(f"[yellow]No variables found with name '{name}'[/yellow]")
return

table = Table(show_header=True, header_style="bold magenta", box=box.ROUNDED)
Expand All @@ -2443,8 +2444,8 @@ def find_by_variable(
res.get('context', '') or 'module'
)

console.print(f"[cyan]Found {len(results)} variable(s) named '{name}':[/cyan]")
console.print(table)
find_result_console.print(f"[cyan]Found {len(results)} variable(s) named '{name}':[/cyan]")
find_result_console.print(table)
finally:
db_manager.close_driver()

Expand Down Expand Up @@ -2482,7 +2483,7 @@ def find_by_content_search(
raise

if not results:
console.print(f"[yellow]No content matches found for '{query}'[/yellow]")
find_result_console.print(f"[yellow]No content matches found for '{query}'[/yellow]")
return

table = Table(show_header=True, header_style="bold magenta", box=box.ROUNDED)
Expand All @@ -2501,8 +2502,8 @@ def find_by_content_search(
location_str
)

console.print(f"[cyan]Found {len(results)} content match(es) for '{query}':[/cyan]")
console.print(table)
find_result_console.print(f"[cyan]Found {len(results)} content match(es) for '{query}':[/cyan]")
find_result_console.print(table)
finally:
db_manager.close_driver()

Expand Down Expand Up @@ -2533,7 +2534,7 @@ def find_by_decorator_search(
results = results[:req_limit]

if not results:
console.print(f"[yellow]No functions found with decorator '@{decorator}'[/yellow]")
find_result_console.print(f"[yellow]No functions found with decorator '@{decorator}'[/yellow]")
return

table = Table(show_header=True, header_style="bold magenta", box=box.ROUNDED)
Expand All @@ -2553,10 +2554,10 @@ def find_by_decorator_search(
decorators_str
)

console.print(f"[cyan]Found {len(results)} function(s) with decorator '@{decorator}':[/cyan]")
console.print(table)
find_result_console.print(f"[cyan]Found {len(results)} function(s) with decorator '@{decorator}':[/cyan]")
find_result_console.print(table)
if truncated:
console.print(f"[dim]... truncated ({req_limit} shown), more exist[/dim]")
find_result_console.print(f"[dim]... truncated ({req_limit} shown), more exist[/dim]")
finally:
db_manager.close_driver()

Expand Down Expand Up @@ -2588,7 +2589,7 @@ def find_by_argument_search(
results = results[:req_limit]

if not results:
console.print(f"[yellow]No functions found with argument name or type '{argument}'[/yellow]")
find_result_console.print(f"[yellow]No functions found with argument name or type '{argument}'[/yellow]")
return

table = Table(show_header=True, header_style="bold magenta", box=box.ROUNDED)
Expand All @@ -2605,10 +2606,10 @@ def find_by_argument_search(
location_str
)

console.print(f"[cyan]Found {len(results)} function(s) with argument '{argument}':[/cyan]")
console.print(table)
find_result_console.print(f"[cyan]Found {len(results)} function(s) with argument '{argument}':[/cyan]")
find_result_console.print(table)
if truncated:
console.print(f"[dim]... truncated ({req_limit} shown), more exist[/dim]")
find_result_console.print(f"[dim]... truncated ({req_limit} shown), more exist[/dim]")
finally:
db_manager.close_driver()

Expand Down
100 changes: 100 additions & 0 deletions tests/integration/test_find_text_output_stream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Text-mode `cgc find` must print its product on stdout.

`analyze` was moved to a stdout console, but the seven `find` subcommands still
carry their result tables on the stderr console, so `cgc find name busy > out.txt`
captures the init chatter and loses the matches. This locks `find` to the same
contract as `analyze`, `stats` and `diagram`, and keeps environment diagnostics
and argument-validation errors off stdout.
"""
import os
import shlex
import subprocess
import sys
from pathlib import Path

import pytest

CGC = f"{shlex.quote(sys.executable)} -m codegraphcontext.cli.main"


@pytest.fixture()
def indexed_repo(tmp_path: Path):
home = tmp_path / "home"
home.mkdir()
repo = tmp_path / "repo"
repo.mkdir()
(repo / "app.py").write_text(
"MAX_LIMIT = 10\n"
"\n"
"def route(path):\n"
" return path\n"
"\n"
"@route\n"
"def busy(x, limit=MAX_LIMIT):\n"
" if x > 0:\n"
" if x > 1:\n"
" if x > 2:\n"
" return 3\n"
" return 2\n"
" return 1\n"
" return 0\n"
"\n"
"def calm():\n"
" return 1\n",
encoding="utf-8",
)
env = os.environ.copy()
env["HOME"] = str(home)
env["DEFAULT_DATABASE"] = "kuzudb"
env["CGC_CONTEXT_MODE"] = "global"
env["PYTHONPATH"] = os.pathsep.join(sys.path)
r = subprocess.run(f"{CGC} index {shlex.quote(str(repo))}", shell=True,
capture_output=True, text=True, env=env, check=False)
assert "Successfully finished indexing" in (r.stdout + r.stderr), (
f"fixture index failed (rc={r.returncode}):\n{r.stdout}\n{r.stderr}"
)
return env


def _run(args: str, env):
return subprocess.run(f"{CGC} {args}", shell=True, capture_output=True,
text=True, env=env)


# Header fragments stay free of result counts so the assertions do not track
# how many rows the indexer happens to emit.
@pytest.mark.parametrize("cmd,header", [
("find name busy", "matches for 'busy':"),
("find pattern busy", "matches for pattern 'busy':"),
("find type function", "functions:"),
("find variable MAX_LIMIT", "variable(s) named 'MAX_LIMIT':"),
("find content busy", "content match(es) for 'busy':"),
("find argument x", "with argument 'x':"),
])
def test_result_table_is_on_stdout_and_not_on_stderr(indexed_repo, cmd, header):
r = _run(cmd, indexed_repo)
assert r.returncode == 0, r.stdout + r.stderr
assert header in r.stdout, (
f"result header missing from stdout:\nSTDOUT:{r.stdout}\nSTDERR:{r.stderr}"
)
assert "Location" in r.stdout, f"table body missing from stdout:\nSTDOUT:{r.stdout}"
assert header not in r.stderr, f"result header duplicated onto stderr:\n{r.stderr}"


@pytest.mark.parametrize("cmd,notice", [
("find name nothing_here", "No code elements found with name 'nothing_here'"),
("find decorator route", "No functions found with decorator '@route'"),
])
def test_empty_result_notice_is_on_stdout(indexed_repo, cmd, notice):
r = _run(cmd, indexed_repo)
assert notice in r.stdout, (
f"an empty result is still a result:\nSTDOUT:{r.stdout}\nSTDERR:{r.stderr}"
)
assert notice not in r.stderr


def test_argument_error_stays_on_stderr(indexed_repo):
r = _run("find name busy --type bogus", indexed_repo)
assert r.returncode == 1
assert "Invalid --type 'bogus'" in r.stderr
assert "Invalid --type" not in r.stdout, "an argument error is not a result"
Loading