Packages
fnord
0.8.87
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/store/project.ex
defmodule Store.Project do
defstruct [
:name,
:store_path,
:source_root,
:exclude,
:conversation_dir,
:exclude_cache
]
@type t :: %__MODULE__{}
@type index_status :: %{
new: [Store.Project.Entry.t()],
stale: [Store.Project.Entry.t()],
deleted: [Store.Project.Entry.t()]
}
@conversation_dir "conversations"
@files_dir "files"
@spec new(String.t(), String.t()) :: t
def new(project_name, store_path) do
settings = Settings.new()
project_data = Settings.get_project_data(settings, project_name) || %{}
exclude = Map.get(project_data, "exclude", [])
root =
Settings.get_project_root_override()
|> case do
nil -> Map.get(project_data, "root", nil)
override -> Path.expand(override)
end
%__MODULE__{
name: project_name,
store_path: store_path,
source_root: root,
exclude: exclude,
conversation_dir: Path.join(store_path, @conversation_dir)
}
end
@spec save_settings(t, String.t() | nil, String.t() | nil) :: t
def save_settings(project, source_root \\ nil, exclude \\ nil) do
# Validate that the project name is not a global config key
unless Settings.is_valid_project_name?(project.name) do
raise ArgumentError,
"Cannot use '#{project.name}' as project name - it conflicts with global configuration"
end
root =
case source_root do
nil -> project.source_root || raise("project root is required")
_ -> Path.expand(source_root)
end
exclude =
case exclude do
nil -> project.exclude || []
[] -> project.exclude || []
exclude -> exclude |> Enum.map(&relative_to!(&1, root))
end
|> Enum.filter(fn exclude ->
exclude
|> Path.expand(root)
|> File.exists?()
|> case do
true ->
true
false ->
UI.warn("Removing non-existent path from project exclude list: #{exclude}")
false
end
end)
settings = Settings.new()
Settings.set_project_data(settings, project.name, %{
"root" => root,
"exclude" => exclude
})
new(project.name, project.store_path)
end
@spec create(t) :: t
def create(project) do
# Ensure base store directory
project.store_path |> File.mkdir_p!()
# Create conversations directory
project.conversation_dir |> File.mkdir_p!()
# Create files directory for future file entries
project |> files_root() |> File.mkdir_p!()
# Initialize notes
Path.join(project.store_path, "notes.md") |> File.touch!()
project
end
@spec files_root(t) :: String.t()
def files_root(%__MODULE__{store_path: store_path}), do: Path.join(store_path, @files_dir)
@spec delete(t) :: :ok
def delete(project) do
Store.Project.FilesDirMigration.ensure_files_dir_layout(project)
# Delete indexed files
files_root(project)
|> Path.join("*/metadata.json")
|> Path.wildcard()
|> Enum.map(&Path.dirname/1)
|> Enum.each(&File.rm_rf!/1)
:ok
end
@spec torch(t) :: :ok
def torch(project) do
# Delete entire directory
File.rm_rf!(project.store_path)
# Remove from settings
Settings.new() |> Settings.delete_project_data(project.name)
:ok
end
@spec make_default_for_session(t) :: t
def make_default_for_session(project) do
Settings.set_project(project.name)
project
end
@doc """
Resolves `path` within the project's source root. Returns `{:ok, path}` if the file exists,
or `{:error, :enoent}` if it does not.
"""
@spec find_file(t, binary) ::
{:ok, binary}
| {:error, :enoent}
| {:error, File.posix()}
def find_file(project, path) do
Util.find_file_within_root(path, project.source_root)
end
# ----------------------------------------------------------------------------
# Entries
# ----------------------------------------------------------------------------
@spec has_index?(t) :: boolean()
def has_index?(project) do
glob =
project
|> files_root()
|> Path.join("**/embeddings.json")
System.cmd("bash", ["-c", ~s[compgen -G "$1" > /dev/null], "--", glob])
|> case do
{_, 0} -> true
_ -> false
end
end
@spec find_entry(t, String.t()) :: {:ok, Store.Project.Entry.t()} | {:error, :enoent}
def find_entry(project, path) do
with {:ok, resolved} <- find_file(project, path) do
{:ok, Store.Project.Entry.new_from_file_path(project, resolved)}
end
end
@spec expand_path(String.t(), t) :: String.t()
def expand_path(path, %Store.Project{} = project) do
Path.expand(path, project.source_root)
end
@spec relative_path(String.t(), t) :: String.t()
def relative_path(path, project) do
path
|> expand_path(project)
|> Path.relative_to(project.source_root)
end
@spec find_path_in_source_root(Store.Project.t(), String.t()) ::
{:ok, :dir | :file | :enoent, String.t()}
def find_path_in_source_root(project, path) do
path = expand_path(path, project)
cond do
File.dir?(path) -> {:ok, :dir, path}
File.regular?(path) -> {:ok, :file, path}
true -> {:ok, :enoent, path}
end
end
@spec exists_in_store?(t) :: boolean()
def exists_in_store?(project) do
path = project.store_path
files = Path.wildcard(Path.join(path, "*"))
cond do
!File.dir?(path) -> false
# There was a bug at one point where store files were deleted but not the
# directory itself. This check is to ensure that the directory is empty.
Enum.empty?(files) -> false
true -> true
end
end
@spec stored_files(t) :: Enumerable.t()
def stored_files(project) do
Store.Project.FilesDirMigration.ensure_files_dir_layout(project)
# Ensure legacy entries are migrated to relative-path scheme
Store.Project.Entry.MigrateAbsToRelPathKeys.ensure_relative_entry_ids(project)
# Scan the files directory for entry metadata
project
|> files_root()
|> Path.join("*/metadata.json")
# Expand the glob
|> Path.wildcard()
# Strip metadata.json from the each listing, leaving the directory path for
# the individual entry.
|> Stream.map(&Path.dirname/1)
# Create an Entry for each directory.
|> Stream.map(&Store.Project.Entry.new_from_entry_path(project, &1))
end
@doc """
Returns no source files when source_root is nil to avoid crashing.
"""
def source_files(%{source_root: nil} = project), do: {project, []}
@spec source_files(t) :: {t, Enumerable.t()}
def source_files(project) do
{project, excluded_paths} = excluded_paths(project)
files =
project
|> list_all_files()
|> Stream.filter(&(!is_hidden?(&1)))
|> Stream.filter(&(!Map.has_key?(excluded_paths, &1)))
|> Stream.filter(&is_text?(&1, project))
|> Stream.map(&Store.Project.Entry.new_from_file_path(project, &1))
{project, files}
end
@spec delete_missing_files(t) :: {t, Enumerable.t()}
def delete_missing_files(project) do
{project, excluded_paths} = excluded_paths(project)
entries =
project
|> stored_files()
|> Stream.filter(&(Map.has_key?(excluded_paths, &1.file) || !File.exists?(&1.file)))
|> Stream.map(fn entry ->
Store.Project.Entry.delete(entry)
entry
end)
{project, entries}
end
@doc """
Returns the status of the index for the given project.
It classifies entries into:
* `:deleted` - entries that were indexed but the source files have been removed
* `:stale` - entries whose indexed metadata is stale compared to the source file
* `:new` - entries for unindexed files that exist in the source
"""
@spec index_status(t) :: index_status
def index_status(project) do
{project, source_stream} = source_files(project)
source =
source_stream
|> Enum.to_list()
stored =
project
|> stored_files()
|> Enum.to_list()
new =
source
|> Enum.filter(fn entry -> not Store.Project.Entry.exists_in_store?(entry) end)
stale =
source
|> Enum.filter(fn entry ->
Store.Project.Entry.exists_in_store?(entry) and Store.Project.Entry.is_stale?(entry)
end)
deleted =
stored
|> Enum.filter(fn entry -> not File.exists?(entry.file) end)
%{
new: new,
stale: stale,
deleted: deleted
}
end
# -----------------------------------------------------------------------------
# Conversations
# -----------------------------------------------------------------------------
@spec conversations(t) :: [Store.Project.Conversation.t()]
def conversations(project) do
Store.Project.Conversation.list(project.store_path)
end
# -----------------------------------------------------------------------------
# Private functions
# -----------------------------------------------------------------------------
defp list_all_files(project) do
args = ["-c", "(find #{project.source_root} -type f || true) | sort"]
case System.cmd("sh", args, stderr_to_stdout: true) do
{output, 0} ->
output
|> String.split("\n", trim: true)
|> Stream.filter(&(!String.ends_with?(&1, ": Permission denied")))
|> Stream.map(&Path.absname(&1, project.source_root))
{error_output, _} ->
raise "find command failed: #{error_output}"
end
end
defp is_hidden?(path) do
cond do
String.contains?(path, "/.github/") -> false
String.starts_with?(path, ".") -> true
String.contains?(path, "/.") -> true
true -> false
end
end
defp is_text?(file_path, project) do
try do
file_path
|> Path.expand(project.source_root)
|> File.stream!(1024)
|> Enum.reduce_while(true, fn chunk, _acc ->
if String.valid?(chunk) do
{:cont, true}
else
{:halt, false}
end
end)
rescue
_ -> false
end
end
# Returns ignored paths via GitCli or empty map when root is nil
defp git_ignored(nil), do: %{}
defp git_ignored(root), do: GitCli.ignored_files(root)
# Computes excluded file paths, caching the result
defp excluded_paths(%{exclude_cache: nil} = project) do
user_excluded =
if is_nil(project.exclude) do
%{}
else
project.exclude
|> Enum.flat_map(fn exclude ->
case find_path_in_source_root(project, exclude) do
# If it's a directory, exclude all files in that directory
{:ok, :dir, path} -> Path.wildcard(Path.join(path, "**/*"), match_dot: true)
# If it's a single file, exclude just that file
{:ok, :file, path} -> [path]
# Otherwise, treat it as a glob
_ -> Path.wildcard(exclude, match_dot: true)
end
end)
# Filter out directories and non-existent files
|> Enum.filter(&File.regular?/1)
# Convert everything to absolute paths
|> Enum.map(&Path.absname/1)
# Convert to a Map for faster lookups
|> Map.new(&{&1, true})
end
excluded = Map.merge(user_excluded, git_ignored(project.source_root))
{%{project | exclude_cache: excluded}, excluded}
end
defp excluded_paths(%{exclude_cache: excluded} = project) do
{project, excluded}
end
defp relative_to!(path, cwd) do
rel =
path
|> Path.expand(cwd)
|> Path.relative_to(cwd)
case Path.safe_relative(rel, cwd) do
{:ok, clean} -> clean
:error -> raise("Error: unable to calculate relative path for #{path} from #{cwd}")
end
end
end